Standardise Switchsockets (Tapo+TP-Link+Tasmota+Shelly) (#3311)

This commit is contained in:
Markus Thierolf 2022-05-05 18:01:16 +02:00 • committed by GitHub
parent 0bd7ea9429
commit 7fb3661c85
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 586 additions and 356 deletions

View file

@ -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
}

View file

@ -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
}

View file

@ -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()
}

View file

@ -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()
}

28
meter/shelly.go Normal file
View file

@ -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)
}

169
meter/shelly/connection.go Normal file
View file

@ -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)
}

View file

@ -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)
}

View file

@ -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 {

27
meter/tasmota.go Normal file
View file

@ -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)
}

View file

@ -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
}

25
meter/tplink.go Normal file
View file

@ -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)
}

114
meter/tplink/connection.go Normal file
View file

@ -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
}

View file

@ -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 }}

View file

@ -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 }}

View file

@ -18,4 +18,4 @@ render: |
{{ if ne .password "" }}
password: {{ .password }}
{{ end }}
standbypower: {{ .standbypower }} # treat as charging above this power
standbypower: {{ .standbypower }}

View file

@ -11,4 +11,4 @@ params:
render: |
type: tplink
uri: {{ .host }}
standbypower: {{ .standbypower }} # treat as charging above this power
standbypower: {{ .standbypower }}

View file

@ -3,7 +3,7 @@ products:
- brand: AVM
description:
generic: FritzDECT
group: sockets
group: switchsockets
params:
- name: usage
choice: ["pv", "charge"]

View file

@ -3,7 +3,6 @@ products:
- brand: Shelly
description:
generic: 3EM
group: sockets
params:
- name: usage
choice: ["grid", "pv", "charge"]

View file

@ -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

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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

View file

@ -1,7 +1,7 @@
product:
brand: AVM
description: FritzDECT
group: Steckdosen
group: Schaltbare Steckdosen
render:
- usage: pv
default: |

View file

@ -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

View file

@ -1,7 +1,6 @@
product:
brand: Shelly
description: 3EM
group: Steckdosen
render:
- usage: grid
default: |

View file

@ -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

View file

@ -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