diff --git a/charger/tapo.go b/charger/tapo.go index dcc9a539e..2aa503f77 100644 --- a/charger/tapo.go +++ b/charger/tapo.go @@ -55,17 +55,12 @@ func NewTapo(embed embed, uri, user, password string, standbypower float64) (*Ta // Enabled implements the api.Charger interface func (c *Tapo) Enabled() (bool, error) { - resp, err := c.conn.ExecCmd("get_device_info", false) - if err != nil { - return false, err - } - return resp.Result.DeviceON, nil + return c.conn.Enabled() } // Enable implements the api.Charger interface func (c *Tapo) Enable(enable bool) error { - _, err := c.conn.ExecCmd("set_device_info", enable) - return err + return c.conn.Enable(enable) } var _ api.ChargeRater = (*Tapo)(nil) diff --git a/go.mod b/go.mod index 95af0e546..6536412a4 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,8 @@ module github.com/evcc-io/evcc -go 1.21 +go 1.21.1 + +toolchain go1.21.3 require ( dario.cat/mergo v1.0.0 @@ -47,6 +49,7 @@ require ( github.com/hashicorp/go-version v1.6.0 github.com/hasura/go-graphql-client v0.10.0 github.com/influxdata/influxdb-client-go/v2 v2.12.5-0.20231103130105-12eadbdbefaf + github.com/insomniacslk/tapo v0.0.0-20231102105048-52dbe7a83ca7 github.com/itchyny/gojq v0.12.13 github.com/jeremywohl/flatten v1.0.1 github.com/jinzhu/copier v0.4.0 @@ -63,7 +66,6 @@ require ( github.com/lunixbochs/struc v0.0.0-20200707160740-784aaebc1d40 github.com/mabunixda/wattpilot v1.6.2 github.com/manifoldco/promptui v0.9.0 - github.com/mergermarket/go-pkcs7 v0.0.0-20170926155232-153b18ea13c9 github.com/mitchellh/go-homedir v1.1.0 github.com/mitchellh/mapstructure v1.5.0 github.com/mlnoga/rct v0.1.2-0.20230731074838-03eacb926f99 @@ -140,6 +142,7 @@ require ( github.com/imdario/mergo v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/influxdata/line-protocol v0.0.0-20210922203350-b1ad95c89adf // indirect + github.com/insomniacslk/xjson v0.0.0-20231023101448-2249e546a131 // indirect github.com/itchyny/timefmt-go v0.1.5 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect @@ -151,6 +154,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect + github.com/mergermarket/go-pkcs7 v0.0.0-20170926155232-153b18ea13c9 // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/miekg/dns v1.1.56 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect diff --git a/go.sum b/go.sum index 442fb6a28..a37ee5283 100644 --- a/go.sum +++ b/go.sum @@ -424,6 +424,10 @@ github.com/influxdata/influxdb-client-go/v2 v2.12.5-0.20231103130105-12eadbdbefa github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo= github.com/influxdata/line-protocol v0.0.0-20210922203350-b1ad95c89adf h1:7JTmneyiNEwVBOHSjoMxiWAqB992atOeepeFYegn5RU= github.com/influxdata/line-protocol v0.0.0-20210922203350-b1ad95c89adf/go.mod h1:xaLFMmpvUxqXtVkUJfg9QmT88cDaCJ3ZKgdZ78oO8Qo= +github.com/insomniacslk/tapo v0.0.0-20231102105048-52dbe7a83ca7 h1:HqhTgNBPK6hxSV2Ib+7bZUWNdsIlydgc3Q0T06pEbJc= +github.com/insomniacslk/tapo v0.0.0-20231102105048-52dbe7a83ca7/go.mod h1:Ac/4L4hAA14H3yYtFk/ZfgpYZ7knJhNuNKmGl/+YDik= +github.com/insomniacslk/xjson v0.0.0-20231023101448-2249e546a131 h1:bVGPuMhjgFtxVdQGfYnFq+EnCqArOAjLNciow/nArwE= +github.com/insomniacslk/xjson v0.0.0-20231023101448-2249e546a131/go.mod h1:Z4EVr4bVv9LZbbje9xyZEyOLpdCOmCvr5S9BJtrdTfw= github.com/itchyny/gojq v0.12.13 h1:IxyYlHYIlspQHHTE0f3cJF0NKDMfajxViuhBLnHd/QU= github.com/itchyny/gojq v0.12.13/go.mod h1:JzwzAqenfhrPUuwbmEz3nu3JQmFLlQTQMUcOdnu/Sf4= github.com/itchyny/timefmt-go v0.1.5 h1:G0INE2la8S6ru/ZI5JecgyzbbJNs5lG1RcBqa7Jm6GE= diff --git a/meter/tapo/connection.go b/meter/tapo/connection.go index 6a635b9da..d4a0a43c9 100644 --- a/meter/tapo/connection.go +++ b/meter/tapo/connection.go @@ -1,49 +1,19 @@ 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" + "net/netip" + "net/url" "strings" - "time" "github.com/evcc-io/evcc/util" - "github.com/evcc-io/evcc/util/request" - "github.com/mergermarket/go-pkcs7" + "github.com/insomniacslk/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 - -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 + plug tapo.Plug lasttodayenergy int64 energy int64 } @@ -52,365 +22,87 @@ type Connection struct { // 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") + url, err := url.Parse(uri) + if err != nil { + return nil, fmt.Errorf("invalid url: %s", uri) + } + + addr, err := netip.ParseAddr(url.Hostname()) + if err != nil { + return nil, fmt.Errorf("invalid ip address: %s", 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").Redact(user, password) + + plug := tapo.NewPlug(addr, nil) + if err := plug.Handshake(user, password); err != nil { + return nil, fmt.Errorf("login failed: %w", err) } - 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)), + log: log, + plug: *plug, } - conn.Client.Timeout = Timeout + res, err := conn.plug.GetDeviceInfo() + if err != nil { + return nil, err + } + conn.log.DEBUG.Printf("%s %s connected (fw:%s,hw:%s,mac:%s)", res.Type, res.Model, res.FWVersion, res.HWVersion, res.MAC) 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 - } - - if err := d.CheckErrorCode(res.ErrorCode); 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 +// Enable implements the api.Charger interface +func (c *Connection) Enable(enable bool) error { + return c.plug.SetDeviceInfo(enable) } -// Handshake provides the Tapo device session cookie and encryption cipher. -func (d *Connection) Handshake() error { - privKey, pubKey, err := GenerateRSAKeys() +// Enabled implements the api.Charger interface +func (c *Connection) Enabled() (bool, error) { + resp, err := c.plug.GetDeviceInfo() if err != nil { - return err + return false, 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) + return resp.DeviceON, nil } // CurrentPower provides current power consuption -func (d *Connection) CurrentPower() (float64, error) { - resp, err := d.ExecCmd("get_energy_usage", false) +func (c *Connection) CurrentPower() (float64, error) { + resp, err := c.plug.GetEnergyUsage() if err != nil { - return 0, err + if strings.Contains(err.Error(), "-1001") { + c.log.DEBUG.Printf("meter not available") + return 0, nil + } else { + return 0, err + } } - return float64(resp.Result.Current_Power) / 1e3, nil + return float64(resp.CurrentPower) / 1e3, nil } // ChargedEnergy collects the daily charged energy -func (d *Connection) ChargedEnergy() (float64, error) { - resp, err := d.ExecCmd("get_energy_usage", false) +func (c *Connection) ChargedEnergy() (float64, error) { + resp, err := c.plug.GetEnergyUsage() 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 - } - - // Login atempt in case of tapo switch connection hicups - if res.ErrorCode == 9999 { - if err := d.Login(); err != nil { - return nil, err - } - - if err := d.DoJSON(req, &res); err != nil { - return nil, err + if strings.Contains(err.Error(), "-1001") { + c.log.DEBUG.Printf("meter not available") + return 0, nil + } else { + return 0, err } } - if err := d.CheckErrorCode(res.ErrorCode); err != nil { - return nil, err + if int64(resp.TodayEnergy) > c.lasttodayenergy { + c.energy = c.energy + (int64(resp.TodayEnergy) - c.lasttodayenergy) } + c.lasttodayenergy = int64(resp.TodayEnergy) - 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 + return float64(c.energy) / 1000, nil } diff --git a/meter/tapo/types.go b/meter/tapo/types.go deleted file mode 100644 index 4a8027ed0..000000000 --- a/meter/tapo/types.go +++ /dev/null @@ -1,52 +0,0 @@ -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"` -}