From 7fb3661c85b8d3a9ac99742cbc141ad4d45ae2e7 Mon Sep 17 00:00:00 2001 From: Markus Thierolf <77847348+thierolm@users.noreply.github.com> Date: Thu, 5 May 2022 18:01:16 +0200 Subject: [PATCH] Standardise Switchsockets (Tapo+TP-Link+Tasmota+Shelly) (#3311) --- charger/shelly.go | 165 +++---------------- charger/tapo.go | 16 -- charger/tasmota.go | 64 ++------ charger/tplink.go | 124 ++++---------- meter/shelly.go | 28 ++++ meter/shelly/connection.go | 169 ++++++++++++++++++++ {charger => meter}/shelly/types.go | 0 meter/tapo.go | 6 - meter/tapo/connection.go | 14 +- meter/tasmota.go | 27 ++++ meter/tasmota/connection.go | 75 +++++++++ {charger => meter}/tasmota/types.go | 0 meter/tplink.go | 25 +++ meter/tplink/connection.go | 114 +++++++++++++ {charger => meter}/tplink/types.go | 0 {charger => meter}/tplink/types_test.go | 0 templates/definition/charger/fritzdect.yaml | 2 +- templates/definition/charger/shelly.yaml | 13 +- templates/definition/charger/tasmota.yaml | 2 +- templates/definition/charger/tplink.yaml | 2 +- templates/definition/meter/fritzdect.yaml | 2 +- templates/definition/meter/shelly-3em.yaml | 1 - templates/definition/meter/shelly-pm1.yaml | 21 +-- templates/definition/meter/tasmota.yaml | 23 +-- templates/definition/meter/tplink.yaml | 13 ++ templates/docs/charger/shelly_0.yaml | 3 + templates/docs/meter/fritzdect_0.yaml | 2 +- templates/docs/meter/shelly-1pm_0.yaml | 3 +- templates/docs/meter/shelly-3em_0.yaml | 1 - templates/docs/meter/tasmota_0.yaml | 16 +- templates/docs/meter/tplink_0.yaml | 11 ++ 31 files changed, 586 insertions(+), 356 deletions(-) create mode 100644 meter/shelly.go create mode 100644 meter/shelly/connection.go rename {charger => meter}/shelly/types.go (100%) create mode 100644 meter/tasmota.go create mode 100644 meter/tasmota/connection.go rename {charger => meter}/tasmota/types.go (100%) create mode 100644 meter/tplink.go create mode 100644 meter/tplink/connection.go rename {charger => meter}/tplink/types.go (100%) rename {charger => meter}/tplink/types_test.go (100%) create mode 100644 templates/definition/meter/tplink.yaml create mode 100644 templates/docs/meter/tplink_0.yaml diff --git a/charger/shelly.go b/charger/shelly.go index b93c31bb0..1b271913b 100644 --- a/charger/shelly.go +++ b/charger/shelly.go @@ -1,27 +1,16 @@ package charger import ( - "errors" "fmt" - "math" - "net/http" - "strings" "github.com/evcc-io/evcc/api" - "github.com/evcc-io/evcc/charger/shelly" + "github.com/evcc-io/evcc/meter/shelly" "github.com/evcc-io/evcc/util" - "github.com/evcc-io/evcc/util/request" - "github.com/evcc-io/evcc/util/transport" - "github.com/jpfielding/go-http-digest/pkg/digest" ) // Shelly charger implementation type Shelly struct { - *request.Helper - log *util.Logger - uri string - gen int // Shelly api generation - channel int + conn *shelly.Connection standbypower float64 } @@ -43,107 +32,32 @@ func NewShellyFromConfig(other map[string]interface{}) (api.Charger, error) { return nil, err } - if cc.URI == "" { - return nil, errors.New("missing uri") - } - return NewShelly(cc.URI, cc.User, cc.Password, cc.Channel, cc.StandbyPower) } // NewShelly creates Shelly charger func NewShelly(uri, user, password string, channel int, standbypower float64) (*Shelly, error) { - for _, suffix := range []string{"/", "/rcp", "/shelly"} { - uri = strings.TrimSuffix(uri, suffix) - } - - log := util.NewLogger("shelly") - client := request.NewHelper(log) - - // Shelly Gen1 and Gen2 families expose the /shelly endpoint - var resp shelly.DeviceInfo - if err := client.GetJSON(fmt.Sprintf("%s/shelly", util.DefaultScheme(uri, "http")), &resp); err != nil { + conn, err := shelly.NewConnection(uri, user, password, channel) + if err != nil { return nil, err } - c := &Shelly{ - Helper: client, - log: log, - channel: channel, + shelly := &Shelly{ + conn: conn, standbypower: standbypower, - gen: resp.Gen, } - c.Client.Transport = request.NewTripper(log, transport.Insecure()) - - if (resp.Auth || resp.AuthEn) && (user == "" || password == "") { - return c, fmt.Errorf("%s (%s) missing user/password", resp.Model, resp.Mac) - } - - switch c.gen { - case 0, 1: - // Shelly GEN 1 API - // https://shelly-api-docs.shelly.cloud/gen1/#shelly-family-overview - c.uri = util.DefaultScheme(uri, "http") - if user != "" { - log.Redact(transport.BasicAuthHeader(user, password)) - c.Client.Transport = transport.BasicAuth(user, password, c.Client.Transport) - } - - if resp.NumMeters == 0 { - // Shelly1 force static mode with fake power http://192.168.178.xxx/settings/power/0?power=standbypower+1 - uri := fmt.Sprintf("%s/settings/power/%d?power=%d", c.uri, c.channel, int(math.Abs(c.standbypower))) - if err := c.GetJSON(uri, &resp); err != nil { - return c, err - } - } - - case 2: - // Shelly GEN 2 API - // https://shelly-api-docs.shelly.cloud/gen2/ - c.uri = fmt.Sprintf("%s/rpc", util.DefaultScheme(uri, "http")) - if user != "" { - c.Client.Transport = digest.NewTransport(user, password, c.Client.Transport) - } - - default: - return c, fmt.Errorf("%s (%s) unknown api generation (%d)", resp.Type, resp.Model, c.gen) - } - - return c, nil + return shelly, nil } // Enabled implements the api.Charger interface func (c *Shelly) Enabled() (bool, error) { - switch c.gen { - case 0, 1: - var resp shelly.Gen1SwitchResponse - uri := fmt.Sprintf("%s/relay/%d", c.uri, c.channel) - err := c.GetJSON(uri, &resp) - return resp.Ison, err - - default: - var resp shelly.Gen2SwitchResponse - err := c.execGen2Cmd("Switch.GetStatus", false, &resp) - return resp.Output, err - } + return c.conn.Enabled() } // Enable implements the api.Charger interface func (c *Shelly) Enable(enable bool) error { - var err error - onoff := map[bool]string{true: "on", false: "off"} - - switch c.gen { - case 0, 1: - var resp shelly.Gen1SwitchResponse - uri := fmt.Sprintf("%s/relay/%d?turn=%s", c.uri, c.channel, onoff[enable]) - err = c.GetJSON(uri, &resp) - - default: - var resp shelly.Gen2SwitchResponse - err = c.execGen2Cmd("Switch.Set", enable, &resp) - } - + err := c.conn.Enable(enable) if err != nil { return err } @@ -153,6 +67,7 @@ func (c *Shelly) Enable(enable bool) error { case err != nil: return err case enable != enabled: + onoff := map[bool]string{true: "on", false: "off"} return fmt.Errorf("switch %s failed", onoff[enable]) default: return nil @@ -192,61 +107,21 @@ var _ api.Meter = (*Shelly)(nil) // CurrentPower implements the api.Meter interface func (c *Shelly) CurrentPower() (float64, error) { var power float64 - switch c.gen { - case 0, 1: - var resp shelly.Gen1StatusResponse - uri := fmt.Sprintf("%s/status", c.uri) - if err := c.GetJSON(uri, &resp); err != nil { - return 0, err - } - if c.channel >= len(resp.Meters) { - return 0, errors.New("invalid channel, missing power meter") - } - - power = resp.Meters[c.channel].Power - - default: - var resp shelly.Gen2StatusResponse - if err := c.execGen2Cmd("Shelly.GetStatus", false, &resp); err != nil { - return 0, err - } - - switch c.channel { - case 1: - power = resp.Switch1.Apower - case 2: - power = resp.Switch2.Apower - default: - power = resp.Switch0.Apower + // set fix static power in static mode + if c.standbypower < 0 { + on, err := c.Enabled() + if on { + power = -c.standbypower } + return power, err } - // ignore standby power - if power <= c.standbypower { + // ignore power in standby mode + power, err := c.conn.CurrentPower() + if c.standbypower >= 0 && power <= c.standbypower { power = 0 } - return power, nil -} - -// execGen2Cmd executes a shelly api gen1/gen2 command and provides the response -func (c *Shelly) execGen2Cmd(method string, enable bool, res interface{}) error { - // Shelly gen 2 rfc7616 authentication - // https://shelly-api-docs.shelly.cloud/gen2/Overview/CommonDeviceTraits#authentication - // https://datatracker.ietf.org/doc/html/rfc7616 - - data := &shelly.Gen2RpcPost{ - Id: c.channel, - On: enable, - Src: "evcc", - Method: method, - } - - req, err := request.New(http.MethodPost, fmt.Sprintf("%s/%s", c.uri, method), request.MarshalJSON(data), request.JSONEncoding) - if err != nil { - return err - } - - return c.DoJSON(req, &res) + return power, err } diff --git a/charger/tapo.go b/charger/tapo.go index 399ea699d..13b8bcbb4 100644 --- a/charger/tapo.go +++ b/charger/tapo.go @@ -1,10 +1,6 @@ package charger import ( - "errors" - "fmt" - "strings" - "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/meter/tapo" "github.com/evcc-io/evcc/util" @@ -33,19 +29,11 @@ func NewTapoFromConfig(other map[string]interface{}) (api.Charger, error) { 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, err := tapo.NewConnection(uri, user, password) if err != nil { return nil, err @@ -56,10 +44,6 @@ func NewTapo(uri, user, password string, standbypower float64) (*Tapo, error) { standbypower: standbypower, } - if user == "" || password == "" { - return tapo, fmt.Errorf("missing user or password") - } - return tapo, nil } diff --git a/charger/tasmota.go b/charger/tasmota.go index 4da7f9cce..b5ba865af 100644 --- a/charger/tasmota.go +++ b/charger/tasmota.go @@ -2,15 +2,10 @@ package charger import ( "errors" - "fmt" - "net/url" - "strings" "github.com/evcc-io/evcc/api" - "github.com/evcc-io/evcc/charger/tasmota" + "github.com/evcc-io/evcc/meter/tasmota" "github.com/evcc-io/evcc/util" - "github.com/evcc-io/evcc/util/request" - "github.com/evcc-io/evcc/util/transport" ) // Tasmota project homepage @@ -20,9 +15,8 @@ import ( // Tasmota charger implementation type Tasmota struct { - *request.Helper - uri, user, password string - standbypower float64 + conn *tasmota.Connection + standbypower float64 } func init() { @@ -41,25 +35,20 @@ func NewTasmotaFromConfig(other map[string]interface{}) (api.Charger, error) { return nil, err } - if cc.URI == "" { - return nil, errors.New("missing uri") - } - return NewTasmota(cc.URI, cc.User, cc.Password, cc.StandbyPower) } // NewTasmota creates Tasmota charger func NewTasmota(uri, user, password string, standbypower float64) (*Tasmota, error) { - log := util.NewLogger("tasmota") - c := &Tasmota{ - Helper: request.NewHelper(log), - uri: util.DefaultScheme(strings.TrimRight(uri, "/"), "http"), - user: user, - password: password, - standbypower: standbypower, + conn, err := tasmota.NewConnection(uri, user, password) + if err != nil { + return nil, err } - c.Client.Transport = request.NewTripper(log, transport.Insecure()) + c := &Tasmota{ + conn: conn, + standbypower: standbypower, + } return c, nil } @@ -67,7 +56,7 @@ func NewTasmota(uri, user, password string, standbypower float64) (*Tasmota, err // Enabled implements the api.Charger interface func (c *Tasmota) Enabled() (bool, error) { var res tasmota.StatusResponse - err := c.GetJSON(c.cmdUri("Status 0"), &res) + err := c.conn.ExecCmd("Status 0", &res) return res.Status.Power == 1, err } @@ -79,7 +68,7 @@ func (c *Tasmota) Enable(enable bool) error { if enable { cmd = "Power on" } - err := c.GetJSON(c.cmdUri(cmd), &res) + err := c.conn.ExecCmd(cmd, &res) switch { case err != nil: @@ -127,7 +116,7 @@ var _ api.Meter = (*Tasmota)(nil) func (c *Tasmota) CurrentPower() (float64, error) { var power float64 - // static mode + // set fix static power in static mode if c.standbypower < 0 { on, err := c.Enabled() if on { @@ -136,13 +125,9 @@ func (c *Tasmota) CurrentPower() (float64, error) { return power, err } - // standby power mode - var res tasmota.StatusSNSResponse - err := c.GetJSON(c.cmdUri("Status 8"), &res) - - // ignore standby power - power = float64(res.StatusSNS.Energy.Power) - if power < c.standbypower { + // ignore power in standby mode + power, err := c.conn.CurrentPower() + if c.standbypower >= 0 && power <= c.standbypower { power = 0 } @@ -153,20 +138,5 @@ var _ api.MeterEnergy = (*Tasmota)(nil) // TotalEnergy implements the api.MeterEnergy interface func (c *Tasmota) TotalEnergy() (float64, error) { - var resp tasmota.StatusSNSResponse - err := c.GetJSON(c.cmdUri("Status 8"), &resp) - - return resp.StatusSNS.Energy.Total, err -} - -// cmdUri creates the Tasmota command web request -// https://tasmota.github.io/docs/Commands/#with-web-requests -func (c *Tasmota) cmdUri(cmd string) string { - parameters := url.Values{ - "user": []string{c.user}, - "password": []string{c.password}, - "cmnd": []string{cmd}, - } - - return fmt.Sprintf("%s/cm?%s", c.uri, parameters.Encode()) + return c.conn.TotalEnergy() } diff --git a/charger/tplink.go b/charger/tplink.go index 13d157340..212594fd7 100644 --- a/charger/tplink.go +++ b/charger/tplink.go @@ -1,24 +1,18 @@ package charger import ( - "bytes" - "encoding/binary" - "encoding/json" "errors" "fmt" - "net" "strings" - "time" "github.com/evcc-io/evcc/api" - "github.com/evcc-io/evcc/charger/tplink" + "github.com/evcc-io/evcc/meter/tplink" "github.com/evcc-io/evcc/util" ) // TPLink charger implementation type TPLink struct { - log *util.Logger - uri string + conn *tplink.Connection standbypower float64 } @@ -46,9 +40,13 @@ func NewTPLinkFromConfig(other map[string]interface{}) (api.Charger, error) { // NewTPLink creates TP-Link charger func NewTPLink(uri string, standbypower float64) (*TPLink, error) { + conn, err := tplink.NewConnection(uri) + if err != nil { + return nil, err + } + c := &TPLink{ - log: util.NewLogger("tplink"), - uri: net.JoinHostPort(uri, "9999"), + conn: conn, standbypower: standbypower, } return c, nil @@ -56,35 +54,35 @@ func NewTPLink(uri string, standbypower float64) (*TPLink, error) { // Enabled implements the api.Charger interface func (c *TPLink) Enabled() (bool, error) { - var resp tplink.SystemResponse - if err := c.execCmd(`{"system":{"get_sysinfo":null}}`, &resp); err != nil { + var res tplink.SystemResponse + if err := c.conn.ExecCmd(`{"system":{"get_sysinfo":null}}`, &res); err != nil { return false, err } - if err := resp.System.GetSysinfo.ErrCode; err != 0 { + if err := res.System.GetSysinfo.ErrCode; err != 0 { return false, fmt.Errorf("get_sysinfo error %d", err) } - if !strings.Contains(resp.System.GetSysinfo.Feature, "ENE") { - return false, errors.New(resp.System.GetSysinfo.Model + " not supported, energy meter feature missing") + if !strings.Contains(res.System.GetSysinfo.Feature, "ENE") { + return false, errors.New(res.System.GetSysinfo.Model + " not supported, energy meter feature missing") } - return resp.System.GetSysinfo.RelayState == 1, nil + return res.System.GetSysinfo.RelayState == 1, nil } // Enable implements the api.Charger interface func (c *TPLink) Enable(enable bool) error { + var res tplink.SystemResponse cmd := `{"system":{"set_relay_state":{"state":0}}}` if enable { cmd = `{"system":{"set_relay_state":{"state":1}}}` } - var resp tplink.SystemResponse - if err := c.execCmd(cmd, &resp); err != nil { + if err := c.conn.ExecCmd(cmd, &res); err != nil { return err } - if err := resp.System.SetRelayState.ErrCode; err != 0 { + if err := res.System.SetRelayState.ErrCode; err != 0 { return fmt.Errorf("set_relay_state error %d", err) } @@ -123,89 +121,29 @@ var _ api.Meter = (*TPLink)(nil) // CurrentPower implements the api.Meter interface func (c *TPLink) CurrentPower() (float64, error) { - var resp tplink.EmeterResponse - if err := c.execCmd(`{"emeter":{"get_realtime":null}}`, &resp); err != nil { - return 0, err + var power float64 + + // set fix static power in static mode + if c.standbypower < 0 { + on, err := c.Enabled() + if on { + power = -c.standbypower + } + return power, err } - if err := resp.Emeter.GetRealtime.ErrCode; err != 0 { - return 0, fmt.Errorf("get_realtime error %d", err) - } - - power := resp.Emeter.GetRealtime.PowerMw / 1000 - if power == 0 { - power = resp.Emeter.GetRealtime.Power - } - - // ignore standby power - if power < c.standbypower { + // ignore power in standby mode + power, err := c.conn.CurrentPower() + if c.standbypower >= 0 && power <= c.standbypower { power = 0 } - return power, nil + return power, err } var _ api.MeterEnergy = (*TPLink)(nil) // TotalEnergy implements the api.MeterEnergy interface func (c *TPLink) TotalEnergy() (float64, error) { - var resp tplink.EmeterResponse - if err := c.execCmd(`{"emeter":{"get_realtime":null}}`, &resp); err != nil { - return 0, err - } - - if err := resp.Emeter.GetRealtime.ErrCode; err != 0 { - return 0, fmt.Errorf("get_realtime error %d", err) - } - - energy := resp.Emeter.GetRealtime.TotalWh / 1000 - if energy == 0 { - energy = resp.Emeter.GetRealtime.Total - } - - return energy, nil -} - -// execCmd executes an TP-Link Smart Home Protocol command and provides the response -func (c *TPLink) execCmd(cmd string, res interface{}) error { - // encode command message - buf := bytes.NewBuffer([]byte{0, 0, 0, 0}) - var key byte = 171 // initialization vector - for i := 0; i < len(cmd); i++ { - key = key ^ cmd[i] - _ = buf.WriteByte(key) - } - - // write 4 bytes command length to start of buffer - binary.BigEndian.PutUint32(buf.Bytes(), uint32(buf.Len()-4)) - - // open connection via TP-Link Smart Home Protocol - conn, err := net.DialTimeout("tcp", c.uri, 5*time.Second) - if err != nil { - return err - } - defer conn.Close() - - // send command - if _, err = buf.WriteTo(conn); err != nil { - return err - } - - // read response - resp := make([]byte, 8192) - len, err := conn.Read(resp) - if err != nil { - return err - } - - // decode response message - key = 171 // reset initialization vector - for i := 4; i < len; i++ { - dec := key ^ resp[i] - key = resp[i] - _ = buf.WriteByte(dec) - } - c.log.TRACE.Printf("recv: %s", buf.String()) - - return json.Unmarshal(buf.Bytes(), res) + return c.conn.TotalEnergy() } diff --git a/meter/shelly.go b/meter/shelly.go new file mode 100644 index 000000000..1d153fc87 --- /dev/null +++ b/meter/shelly.go @@ -0,0 +1,28 @@ +package meter + +import ( + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/meter/shelly" + "github.com/evcc-io/evcc/util" +) + +// Shelly meter implementation +func init() { + registry.Add("shelly", NewShellyFromConfig) +} + +// NewShellyFromConfig creates a Shelly charger from generic config +func NewShellyFromConfig(other map[string]interface{}) (api.Meter, error) { + cc := struct { + URI string + User string + Password string + Channel int + }{} + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + return shelly.NewConnection(cc.URI, cc.User, cc.Password, cc.Channel) +} diff --git a/meter/shelly/connection.go b/meter/shelly/connection.go new file mode 100644 index 000000000..15cdbb0db --- /dev/null +++ b/meter/shelly/connection.go @@ -0,0 +1,169 @@ +package shelly + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" + "github.com/evcc-io/evcc/util/transport" + "github.com/jpfielding/go-http-digest/pkg/digest" +) + +// Connection is the Shelly connection +type Connection struct { + *request.Helper + uri string + channel int + gen int // Shelly api generation +} + +// NewConnection creates a new Shelly device connection. +func NewConnection(uri, user, password string, channel int) (*Connection, error) { + if uri == "" { + return nil, errors.New("missing uri") + } + + for _, suffix := range []string{"/", "/rcp", "/shelly"} { + uri = strings.TrimSuffix(uri, suffix) + } + + log := util.NewLogger("shelly") + client := request.NewHelper(log) + + // Shelly Gen1 and Gen2 families expose the /shelly endpoint + var resp DeviceInfo + if err := client.GetJSON(fmt.Sprintf("%s/shelly", util.DefaultScheme(uri, "http")), &resp); err != nil { + return nil, err + } + + conn := &Connection{ + Helper: client, + channel: channel, + gen: resp.Gen, + } + + conn.Client.Transport = request.NewTripper(log, transport.Insecure()) + + if (resp.Auth || resp.AuthEn) && (user == "" || password == "") { + return conn, fmt.Errorf("%s (%s) missing user/password", resp.Model, resp.Mac) + } + + switch conn.gen { + case 0, 1: + // Shelly GEN 1 API + // https://shelly-api-docs.shelly.cloud/gen1/#shelly-family-overview + conn.uri = util.DefaultScheme(uri, "http") + if user != "" { + log.Redact(transport.BasicAuthHeader(user, password)) + conn.Client.Transport = transport.BasicAuth(user, password, conn.Client.Transport) + } + + case 2: + // Shelly GEN 2 API + // https://shelly-api-docs.shelly.cloud/gen2/ + conn.uri = fmt.Sprintf("%s/rpc", util.DefaultScheme(uri, "http")) + if user != "" { + conn.Client.Transport = digest.NewTransport(user, password, conn.Client.Transport) + } + + default: + return conn, fmt.Errorf("%s (%s) unknown api generation (%d)", resp.Type, resp.Model, conn.gen) + } + + return conn, nil +} + +// CurrentPower implements the api.Meter interface +func (d *Connection) CurrentPower() (float64, error) { + var power float64 + switch d.gen { + case 0, 1: + var res Gen1StatusResponse + uri := fmt.Sprintf("%s/status", d.uri) + if err := d.GetJSON(uri, &res); err != nil { + return 0, err + } + + if d.channel >= len(res.Meters) { + return 0, errors.New("invalid channel, missing power meter") + } + + power = res.Meters[d.channel].Power + + default: + var res Gen2StatusResponse + if err := d.execGen2Cmd("Shelly.GetStatus", false, &res); err != nil { + return 0, err + } + + switch d.channel { + case 1: + power = res.Switch1.Apower + case 2: + power = res.Switch2.Apower + default: + power = res.Switch0.Apower + } + } + + return power, nil +} + +// Enabled implements the api.Charger interface +func (d *Connection) Enabled() (bool, error) { + switch d.gen { + case 0, 1: + var res Gen1SwitchResponse + uri := fmt.Sprintf("%s/relay/%d", d.uri, d.channel) + err := d.GetJSON(uri, &res) + return res.Ison, err + + default: + var res Gen2SwitchResponse + err := d.execGen2Cmd("Switch.GetStatus", false, &res) + return res.Output, err + } +} + +// Enable implements the api.Charger interface +func (d *Connection) Enable(enable bool) error { + var err error + onoff := map[bool]string{true: "on", false: "off"} + + switch d.gen { + case 0, 1: + var res Gen1SwitchResponse + uri := fmt.Sprintf("%s/relay/%d?turn=%s", d.uri, d.channel, onoff[enable]) + err = d.GetJSON(uri, &res) + + default: + var res Gen2SwitchResponse + err = d.execGen2Cmd("Switch.Set", enable, &res) + } + + return err +} + +// execGen2Cmd executes a shelly api gen1/gen2 command and provides the response +func (d *Connection) execGen2Cmd(method string, enable bool, res interface{}) error { + // Shelly gen 2 rfc7616 authentication + // https://shelly-api-docs.shelly.cloud/gen2/Overview/CommonDeviceTraits#authentication + // https://datatracker.ietf.org/doc/html/rfc7616 + + data := &Gen2RpcPost{ + Id: d.channel, + On: enable, + Src: "evcc", + Method: method, + } + + req, err := request.New(http.MethodPost, fmt.Sprintf("%s/%s", d.uri, method), request.MarshalJSON(data), request.JSONEncoding) + if err != nil { + return err + } + + return d.DoJSON(req, &res) +} diff --git a/charger/shelly/types.go b/meter/shelly/types.go similarity index 100% rename from charger/shelly/types.go rename to meter/shelly/types.go diff --git a/meter/tapo.go b/meter/tapo.go index 5799f39a5..636338ebb 100644 --- a/meter/tapo.go +++ b/meter/tapo.go @@ -1,8 +1,6 @@ package meter import ( - "errors" - "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/meter/tapo" "github.com/evcc-io/evcc/util" @@ -25,9 +23,5 @@ func NewTapoFromConfig(other map[string]interface{}) (api.Meter, error) { return nil, err } - if cc.URI == "" { - return nil, errors.New("missing uri") - } - return tapo.NewConnection(cc.URI, cc.User, cc.Password) } diff --git a/meter/tapo/connection.go b/meter/tapo/connection.go index e35867f4f..49e24f491 100644 --- a/meter/tapo/connection.go +++ b/meter/tapo/connection.go @@ -52,6 +52,18 @@ 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") + } + + 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 @@ -208,7 +220,7 @@ func (d *Connection) ExecMethod(method string, deviceOn bool) (*DeviceResponse, return res, nil } -// execCmd executes a Tapo api command and provides the response +// 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 { diff --git a/meter/tasmota.go b/meter/tasmota.go new file mode 100644 index 000000000..5cc52c891 --- /dev/null +++ b/meter/tasmota.go @@ -0,0 +1,27 @@ +package meter + +import ( + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/meter/tasmota" + "github.com/evcc-io/evcc/util" +) + +// Tasmota meter implementation +func init() { + registry.Add("tasmota", NewTasmotaFromConfig) +} + +// NewTapoFromConfig creates a tapo meter from generic config +func NewTasmotaFromConfig(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 + } + + return tasmota.NewConnection(cc.URI, cc.User, cc.Password) +} diff --git a/meter/tasmota/connection.go b/meter/tasmota/connection.go new file mode 100644 index 000000000..53b2693ee --- /dev/null +++ b/meter/tasmota/connection.go @@ -0,0 +1,75 @@ +package tasmota + +import ( + "errors" + "fmt" + "net/url" + "strings" + + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" + "github.com/evcc-io/evcc/util/transport" +) + +// Connection is the Tasmota connection +type Connection struct { + *request.Helper + uri, user, password string +} + +// NewConnection creates Tasmota charger +func NewConnection(uri, user, password string) (*Connection, error) { + if uri == "" { + return nil, errors.New("missing uri") + } + + log := util.NewLogger("tasmota") + c := &Connection{ + Helper: request.NewHelper(log), + uri: util.DefaultScheme(strings.TrimRight(uri, "/"), "http"), + user: user, + password: password, + } + + c.Client.Transport = request.NewTripper(log, transport.Insecure()) + + return c, nil +} + +// ExecCmd executes a Tasmota api command and provides the response +func (d *Connection) ExecCmd(cmd string, res interface{}) error { + parameters := url.Values{ + "user": []string{d.user}, + "password": []string{d.password}, + "cmnd": []string{cmd}, + } + + err := d.GetJSON(fmt.Sprintf("%s/cm?%s", d.uri, parameters.Encode()), res) + if err != nil { + return err + } + + return nil +} + +// CurrentPower provides current power consumption +func (d *Connection) CurrentPower() (float64, error) { + var res *StatusSNSResponse + err := d.ExecCmd("Status 8", &res) + if err != nil { + return 0, err + } + + return float64(res.StatusSNS.Energy.Power), nil +} + +// TotalEnergy implements the api.MeterEnergy interface +func (d *Connection) TotalEnergy() (float64, error) { + var res *StatusSNSResponse + err := d.ExecCmd("Status 8", &res) + if err != nil { + return 0, err + } + + return float64(res.StatusSNS.Energy.Total), nil +} diff --git a/charger/tasmota/types.go b/meter/tasmota/types.go similarity index 100% rename from charger/tasmota/types.go rename to meter/tasmota/types.go diff --git a/meter/tplink.go b/meter/tplink.go new file mode 100644 index 000000000..1b504d67c --- /dev/null +++ b/meter/tplink.go @@ -0,0 +1,25 @@ +package meter + +import ( + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/meter/tplink" + "github.com/evcc-io/evcc/util" +) + +// TP-Link meter implementation +func init() { + registry.Add("tplink", NewTPLinkFromConfig) +} + +// NewTPLinkFromConfig creates a tapo meter from generic config +func NewTPLinkFromConfig(other map[string]interface{}) (api.Meter, error) { + cc := struct { + URI string + }{} + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + return tplink.NewConnection(cc.URI) +} diff --git a/meter/tplink/connection.go b/meter/tplink/connection.go new file mode 100644 index 000000000..5d91765b8 --- /dev/null +++ b/meter/tplink/connection.go @@ -0,0 +1,114 @@ +package tplink + +import ( + "bytes" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "net" + "time" + + "github.com/evcc-io/evcc/util" +) + +// Connection is the TP-Link connection +type Connection struct { + log *util.Logger + uri string +} + +// NewConnection creates TP-Link charger +func NewConnection(uri string) (*Connection, error) { + if uri == "" { + return nil, errors.New("missing uri") + } + + c := &Connection{ + log: util.NewLogger("tplink"), + uri: net.JoinHostPort(uri, "9999"), + } + return c, nil +} + +// ExecCmd executes an TP-Link Smart Home Protocol command and provides the response +func (d *Connection) ExecCmd(cmd string, res interface{}) error { + // encode command message + buf := bytes.NewBuffer([]byte{0, 0, 0, 0}) + var key byte = 171 // initialization vector + for i := 0; i < len(cmd); i++ { + key = key ^ cmd[i] + _ = buf.WriteByte(key) + } + + // write 4 bytes command length to start of buffer + binary.BigEndian.PutUint32(buf.Bytes(), uint32(buf.Len()-4)) + + // open connection via TP-Link Smart Home Protocol + conn, err := net.DialTimeout("tcp", d.uri, 5*time.Second) + if err != nil { + return err + } + defer conn.Close() + + // send command + if _, err = buf.WriteTo(conn); err != nil { + return err + } + + // read response + resp := make([]byte, 8192) + len, err := conn.Read(resp) + if err != nil { + return err + } + + // decode response message + key = 171 // reset initialization vector + for i := 4; i < len; i++ { + dec := key ^ resp[i] + key = resp[i] + _ = buf.WriteByte(dec) + } + d.log.TRACE.Printf("recv: %s", buf.String()) + + return json.Unmarshal(buf.Bytes(), res) +} + +// CurrentPower provides current power consumption +func (d *Connection) CurrentPower() (float64, error) { + var res EmeterResponse + if err := d.ExecCmd(`{"emeter":{"get_realtime":null}}`, &res); err != nil { + return 0, err + } + + if err := res.Emeter.GetRealtime.ErrCode; err != 0 { + return 0, fmt.Errorf("get_realtime error %d", err) + } + + power := res.Emeter.GetRealtime.PowerMw / 1000 + if power == 0 { + power = res.Emeter.GetRealtime.Power + } + + return power, nil +} + +// TotalEnergy implements the api.MeterEnergy interface +func (d *Connection) TotalEnergy() (float64, error) { + var res EmeterResponse + if err := d.ExecCmd(`{"emeter":{"get_realtime":null}}`, &res); err != nil { + return 0, err + } + + if err := res.Emeter.GetRealtime.ErrCode; err != 0 { + return 0, fmt.Errorf("get_realtime error %d", err) + } + + energy := res.Emeter.GetRealtime.TotalWh / 1000 + if energy == 0 { + energy = res.Emeter.GetRealtime.Total + } + + return energy, nil +} diff --git a/charger/tplink/types.go b/meter/tplink/types.go similarity index 100% rename from charger/tplink/types.go rename to meter/tplink/types.go diff --git a/charger/tplink/types_test.go b/meter/tplink/types_test.go similarity index 100% rename from charger/tplink/types_test.go rename to meter/tplink/types_test.go diff --git a/templates/definition/charger/fritzdect.yaml b/templates/definition/charger/fritzdect.yaml index 84e6695ac..f243ee93f 100644 --- a/templates/definition/charger/fritzdect.yaml +++ b/templates/definition/charger/fritzdect.yaml @@ -27,4 +27,4 @@ render: | user: {{ .user }} password: {{ .password }} ain: {{ .ain }} # switch actor identification number without blanks (see AIN number on switch sticker) - standbypower: {{ .standbypower }} # treat as charging above this power + standbypower: {{ .standbypower }} diff --git a/templates/definition/charger/shelly.yaml b/templates/definition/charger/shelly.yaml index 4aca1199a..d8e567587 100644 --- a/templates/definition/charger/shelly.yaml +++ b/templates/definition/charger/shelly.yaml @@ -4,10 +4,17 @@ products: group: switchsockets params: - name: host + - name: user + - name: password + mask: true + - name: channel + default: 0 - name: standbypower default: 15 render: | type: shelly - uri: http://{{ .host }} # shelly device ip address (local) - channel: 0 # shelly device relay channel - standbypower: {{ .standbypower }} # treat as charging above this power + uri: http://{{ .host }} + {{ if ne .user "" }}user: {{ .user }}{{ end }} + {{ if ne .password "" }}password: {{ .password }}{{ end }} + channel: {{ .channel }} # shelly device relay channel + standbypower: {{ .standbypower }} diff --git a/templates/definition/charger/tasmota.yaml b/templates/definition/charger/tasmota.yaml index 418e7a4ed..582ca4b50 100644 --- a/templates/definition/charger/tasmota.yaml +++ b/templates/definition/charger/tasmota.yaml @@ -18,4 +18,4 @@ render: | {{ if ne .password "" }} password: {{ .password }} {{ end }} - standbypower: {{ .standbypower }} # treat as charging above this power + standbypower: {{ .standbypower }} diff --git a/templates/definition/charger/tplink.yaml b/templates/definition/charger/tplink.yaml index 33ee5ec5e..cf07b0e51 100644 --- a/templates/definition/charger/tplink.yaml +++ b/templates/definition/charger/tplink.yaml @@ -11,4 +11,4 @@ params: render: | type: tplink uri: {{ .host }} - standbypower: {{ .standbypower }} # treat as charging above this power + standbypower: {{ .standbypower }} diff --git a/templates/definition/meter/fritzdect.yaml b/templates/definition/meter/fritzdect.yaml index af233ebca..5c14ca901 100644 --- a/templates/definition/meter/fritzdect.yaml +++ b/templates/definition/meter/fritzdect.yaml @@ -3,7 +3,7 @@ products: - brand: AVM description: generic: FritzDECT -group: sockets +group: switchsockets params: - name: usage choice: ["pv", "charge"] diff --git a/templates/definition/meter/shelly-3em.yaml b/templates/definition/meter/shelly-3em.yaml index 3dfad302e..043d3569d 100644 --- a/templates/definition/meter/shelly-3em.yaml +++ b/templates/definition/meter/shelly-3em.yaml @@ -3,7 +3,6 @@ products: - brand: Shelly description: generic: 3EM -group: sockets params: - name: usage choice: ["grid", "pv", "charge"] diff --git a/templates/definition/meter/shelly-pm1.yaml b/templates/definition/meter/shelly-pm1.yaml index ed44fd870..e21d6a1d0 100644 --- a/templates/definition/meter/shelly-pm1.yaml +++ b/templates/definition/meter/shelly-pm1.yaml @@ -3,24 +3,19 @@ products: - brand: Shelly description: generic: 1PM -group: sockets +group: switchsockets params: - name: usage choice: ["pv"] - name: host - name: user - required: false - name: password - required: false mask: true + - name: channel + default: 0 render: | - type: custom - power: - source: http - uri: http://{{ if ne .user "" }}{{ .user }}:{{ .password }}@{{ end }}{{ .host }}/status - jq: .meters | map(.power) | add - energy: - source: http - uri: http://{{ if ne .user "" }}{{ .user }}:{{ .password }}@{{ end }}{{ .host }}/status - jq: .meters | map(.total) | add - scale: 0.0000166666666666667 + type: shelly + uri: http://{{ .host }} # shelly device ip address (local) + {{ if ne .user "" }}user: {{ .user }}{{ end }} + {{ if ne .password "" }}password: {{ .password }}{{ end }} + channel: {{ .channel }} # shelly device relay channel diff --git a/templates/definition/meter/tasmota.yaml b/templates/definition/meter/tasmota.yaml index b9079ba11..d0423988e 100644 --- a/templates/definition/meter/tasmota.yaml +++ b/templates/definition/meter/tasmota.yaml @@ -2,18 +2,19 @@ template: tasmota products: - description: generic: Tasmota -group: sockets +group: switchsockets params: - name: usage - choice: ["grid", "pv", "charge"] + choice: ["pv"] - name: host + - name: user + required: false + default: admin + - name: password + required: false + mask: true render: | - type: custom - power: # power reading (W) - source: http - uri: http://{{ .host }}/cm?cmnd=Status%208 - jq: .StatusSNS.ENERGY.Power - energy: # energy reading (kWh) - source: http - uri: http://{{ .host }}/cm?cmnd=Status%208 - jq: .StatusSNS.ENERGY.Total + type: tasmota + uri: http://{{ .host }} + user: {{ .user }} + password: {{ .password }} diff --git a/templates/definition/meter/tplink.yaml b/templates/definition/meter/tplink.yaml new file mode 100644 index 000000000..f9ad71d4b --- /dev/null +++ b/templates/definition/meter/tplink.yaml @@ -0,0 +1,13 @@ +template: tplink +products: + - brand: TP-Link + description: + generic: H-Series Smart Plug +group: switchsockets +params: + - name: usage + choice: ["pv"] + - name: host +render: | + type: tplink + uri: {{ .host }} diff --git a/templates/docs/charger/shelly_0.yaml b/templates/docs/charger/shelly_0.yaml index 2e8bf094f..eefc8a59c 100644 --- a/templates/docs/charger/shelly_0.yaml +++ b/templates/docs/charger/shelly_0.yaml @@ -6,4 +6,7 @@ render: type: template template: shelly host: 192.0.2.2 # IP-Adresse oder Hostname + user: # Benutzerkonto (bspw. E-Mail Adresse, User Id, etc.) # Optional + password: # Passwort des Benutzerkontos (bei führenden Nullen bitte in einfache Hochkommata setzen) # Optional + channel: 0 # Optional standbypower: 15 # Leistung oberhalb des angegebenen Wertes wird als Ladeleistung gewertet # Optional diff --git a/templates/docs/meter/fritzdect_0.yaml b/templates/docs/meter/fritzdect_0.yaml index 9231cd672..65f8d7c31 100644 --- a/templates/docs/meter/fritzdect_0.yaml +++ b/templates/docs/meter/fritzdect_0.yaml @@ -1,7 +1,7 @@ product: brand: AVM description: FritzDECT - group: Steckdosen + group: Schaltbare Steckdosen render: - usage: pv default: | diff --git a/templates/docs/meter/shelly-1pm_0.yaml b/templates/docs/meter/shelly-1pm_0.yaml index 5c6a04a71..50790399a 100644 --- a/templates/docs/meter/shelly-1pm_0.yaml +++ b/templates/docs/meter/shelly-1pm_0.yaml @@ -1,7 +1,7 @@ product: brand: Shelly description: 1PM - group: Steckdosen + group: Schaltbare Steckdosen render: - usage: pv default: | @@ -11,3 +11,4 @@ render: host: 192.0.2.2 # IP-Adresse oder Hostname user: # Benutzerkonto (bspw. E-Mail Adresse, User Id, etc.) # Optional password: # Passwort des Benutzerkontos (bei führenden Nullen bitte in einfache Hochkommata setzen) # Optional + channel: 0 # Optional diff --git a/templates/docs/meter/shelly-3em_0.yaml b/templates/docs/meter/shelly-3em_0.yaml index a5a86e95b..2707d9c5b 100644 --- a/templates/docs/meter/shelly-3em_0.yaml +++ b/templates/docs/meter/shelly-3em_0.yaml @@ -1,7 +1,6 @@ product: brand: Shelly description: 3EM - group: Steckdosen render: - usage: grid default: | diff --git a/templates/docs/meter/tasmota_0.yaml b/templates/docs/meter/tasmota_0.yaml index e8cf7e02b..d75c2fdec 100644 --- a/templates/docs/meter/tasmota_0.yaml +++ b/templates/docs/meter/tasmota_0.yaml @@ -1,22 +1,12 @@ product: description: Tasmota - group: Steckdosen + group: Schaltbare Steckdosen render: - - usage: grid - default: | - type: template - template: tasmota - usage: grid - host: 192.0.2.2 # IP-Adresse oder Hostname - usage: pv default: | type: template template: tasmota usage: pv host: 192.0.2.2 # IP-Adresse oder Hostname - - usage: charge - default: | - type: template - template: tasmota - usage: charge - host: 192.0.2.2 # IP-Adresse oder Hostname + user: admin # Benutzerkonto (bspw. E-Mail Adresse, User Id, etc.) # Optional + password: # Passwort des Benutzerkontos (bei führenden Nullen bitte in einfache Hochkommata setzen) # Optional diff --git a/templates/docs/meter/tplink_0.yaml b/templates/docs/meter/tplink_0.yaml new file mode 100644 index 000000000..e7b9fde4e --- /dev/null +++ b/templates/docs/meter/tplink_0.yaml @@ -0,0 +1,11 @@ +product: + brand: TP-Link + description: H-Series Smart Plug + group: Schaltbare Steckdosen +render: + - usage: pv + default: | + type: template + template: tplink + usage: pv + host: 192.0.2.2 # IP-Adresse oder Hostname