Skip to content

Commit

Permalink
Add Tapo meter and config template (evcc-io#3287)
Browse files Browse the repository at this point in the history
  • Loading branch information
thierolm authored May 2, 2022
1 parent 3db8e40 commit 1ba3c5d
Show file tree
Hide file tree
Showing 10 changed files with 150 additions and 81 deletions.
2 changes: 1 addition & 1 deletion charger/fritzdect.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import (
"strconv"

"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/meter/fritzdect"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/fritzdect"
)

// AVM FritzBox AHA interface specifications:
Expand Down
78 changes: 22 additions & 56 deletions charger/tapo.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,16 @@ import (
"errors"
"fmt"
"strings"
"time"

"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/charger/tapo"
"github.com/evcc-io/evcc/meter/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
conn *tapo.Connection
standbypower float64
}

func init() {
Expand Down Expand Up @@ -50,7 +46,10 @@ func NewTapo(uri, user, password string, standbypower float64) (*Tapo, error) {
uri = strings.TrimSuffix(uri, suffix)
}

conn := tapo.NewConnection(uri, user, password)
conn, err := tapo.NewConnection(uri, user, password)
if err != nil {
return nil, err
}

tapo := &Tapo{
conn: conn,
Expand All @@ -66,7 +65,7 @@ func NewTapo(uri, user, password string, standbypower float64) (*Tapo, error) {

// Enabled implements the api.Charger interface
func (c *Tapo) Enabled() (bool, error) {
resp, err := c.execTapoCmd("get_device_info", false)
resp, err := c.conn.ExecCmd("get_device_info", false)
if err != nil {
return false, err
}
Expand All @@ -75,7 +74,7 @@ func (c *Tapo) Enabled() (bool, error) {

// Enable implements the api.Charger interface
func (c *Tapo) Enable(enable bool) error {
_, err := c.execTapoCmd("set_device_info", enable)
_, err := c.conn.ExecCmd("set_device_info", enable)
return err
}

Expand All @@ -92,7 +91,7 @@ func (c *Tapo) Status() (api.ChargeStatus, error) {
return res, err
}

power, err := c.CurrentPower()
power, err := c.conn.CurrentPower()
if err != nil {
return res, err
}
Expand All @@ -109,62 +108,29 @@ 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
}
var power float64

// 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
power = -c.standbypower
}
return power, err
}

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)
// ignore power in standby mode
power, err := c.conn.CurrentPower()
if c.standbypower >= 0 && power <= c.standbypower {
power = 0
}
c.lasttodayenergy = resp.Result.Today_Energy

return float64(c.energy) / 1000, nil
return power, err
}

// 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()
}
var _ api.ChargeRater = (*Tapo)(nil)

return c.conn.ExecMethod(method, enable)
// ChargedEnergy implements the api.ChargeRater interface
func (c *Tapo) ChargedEnergy() (float64, error) {
return c.conn.ChargedEnergy()
}
2 changes: 1 addition & 1 deletion meter/fritzdect.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ package meter

import (
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/meter/fritzdect"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/fritzdect"
)

// AVM FritzBox AHA interface specifications:
Expand Down
File renamed without changes.
33 changes: 33 additions & 0 deletions meter/tapo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package meter

import (
"errors"

"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/meter/tapo"
"github.com/evcc-io/evcc/util"
)

// TP-Link Tapo meter implementation
func init() {
registry.Add("tapo", NewTapoFromConfig)
}

// NewTapoFromConfig creates a tapo meter from generic config
func NewTapoFromConfig(other map[string]interface{}) (api.Meter, error) {
cc := struct {
URI string
User string
Password string
}{}

if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}

if cc.URI == "" {
return nil, errors.New("missing uri")
}

return tapo.NewConnection(cc.URI, cc.User, cc.Password)
}
82 changes: 59 additions & 23 deletions charger/tapo/connection.go → meter/tapo/connection.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,17 +43,20 @@ type Connection struct {
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 {
func NewConnection(uri, user, password string) (*Connection, error) {
log := util.NewLogger("tapo")

// nosemgrep:go.lang.security.audit.crypto.use_of_weak_crypto.use-of-sha1
h := sha1.New()
_, _ = h.Write([]byte(user))
_, err := h.Write([]byte(user))
userhash := hex.EncodeToString(h.Sum(nil))

conn := &Connection{
Expand All @@ -66,7 +69,7 @@ func NewConnection(uri, user, password string) *Connection {

conn.Client.Timeout = Timeout

return conn
return conn, err
}

// Login provides the Tapo device session token and MAC address (TerminalUUID).
Expand Down Expand Up @@ -205,61 +208,98 @@ func (d *Connection) ExecMethod(method string, deviceOn bool) (*DeviceResponse,
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) {
treq, err := json.Marshal(taporequest)
payload, err := json.Marshal(taporequest)
if err != nil {
return nil, err
}

d.log.TRACE.Printf("request: %s\n", string(treq))
d.log.TRACE.Printf("request: %s", string(payload))

encryptedRequest, err := d.Cipher.Encrypt(treq)
encryptedRequest, err := d.Cipher.Encrypt(payload)
if err != nil {
return nil, err
}

securedReq := map[string]interface{}{
data := 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{
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 {
if err := d.DoJSON(req, &res); err != nil {
return nil, err
}

if err = d.CheckErrorCode(res.ErrorCode); err != nil {
if err := d.CheckErrorCode(res.ErrorCode); err != nil {
return nil, err
}

b64decodedResp, err := base64.StdEncoding.DecodeString(res.Result.Response)
decodedResponse, err := base64.StdEncoding.DecodeString(res.Result.Response)
if err != nil {
return nil, err
}

decryptedResponse, err := d.Cipher.Decrypt(b64decodedResp)
decryptedResponse, err := d.Cipher.Decrypt(decodedResponse)
if err != nil {
return nil, err
}

d.log.TRACE.Printf("decrypted result: %v\n", string(decryptedResponse))
d.log.TRACE.Printf("decrypted result: %v", string(decryptedResponse))

var deviceResp *DeviceResponse
if err = json.Unmarshal(decryptedResponse, &deviceResp); err != nil {
return deviceResp, err
}
err = json.Unmarshal(decryptedResponse, &deviceResp)

return deviceResp, nil
return deviceResp, err
}

// Tapo helper functions
Expand Down Expand Up @@ -292,6 +332,7 @@ func (c *ConnectionCipher) Encrypt(payload []byte) ([]byte, error) {
if err != nil {
return nil, err
}

encrypter := cipher.NewCBCEncrypter(block, c.Iv)
encryptedPayload := make([]byte, len(paddedPayload))
encrypter.CryptBlocks(encryptedPayload, paddedPayload)
Expand All @@ -310,12 +351,7 @@ func (c *ConnectionCipher) Decrypt(payload []byte) ([]byte, error) {

encrypter.CryptBlocks(decryptedPayload, payload)

unpaddedPayload, err := pkcs7.Unpad(decryptedPayload, aes.BlockSize)
if err != nil {
return nil, err
}

return unpaddedPayload, nil
return pkcs7.Unpad(decryptedPayload, aes.BlockSize)
}

func DumpRSAPEM(pubKey *rsa.PublicKey) ([]byte, error) {
Expand Down
File renamed without changes.
20 changes: 20 additions & 0 deletions templates/definition/meter/tapo.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
template: tapo
products:
- brand: TP-Link
description:
generic: Tapo P-Series Smart Plug
group: switchsockets
params:
- name: usage
choice: ["pv"]
- name: host
- name: user
required: true
- name: password
required: true
mask: true
render: |
type: tapo
uri: http://{{ .host }}
user: {{ .user }}
password: {{ .password }}
13 changes: 13 additions & 0 deletions templates/docs/meter/tapo_0.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
product:
brand: TP-Link
description: Tapo P-Series Smart Plug
group: Schaltbare Steckdosen
render:
- usage: pv
default: |
type: template
template: tapo
usage: pv
host: 192.0.2.2 # IP-Adresse oder Hostname
user: # Benutzerkonto (bspw. E-Mail Adresse, User Id, etc.)
password: # Passwort des Benutzerkontos (bei führenden Nullen bitte in einfache Hochkommata setzen)
Loading

0 comments on commit 1ba3c5d

Please sign in to comment.