Add Shelly-NG 3nd gen devices (#1682)

This commit is contained in:
Markus Thierolf 2021-10-03 11:11:47 +02:00 • committed by GitHub
parent 8facebd7ad
commit cfbb2cbca7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 1330 additions and 40 deletions

View file

@ -3,32 +3,23 @@ package charger
import (
"errors"
"fmt"
"net/url"
"net/http"
"strings"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/charger/shelly"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/basicauth"
"github.com/evcc-io/evcc/util/request"
"github.com/jpfielding/go-http-digest/pkg/digest"
)
// Shelly api homepage
// https://shelly-api-docs.shelly.cloud/#common-http-api
// shellyRelayResponse provides the evcc shelly charger enabled information
type shellyRelayResponse struct {
Ison bool `json:"ison,omitempty"`
}
// shellyStatusResponse provides the evcc shelly charger current power information
type shellyStatusResponse struct {
Meters []struct {
Power float64 `json:"power,omitempty"`
} `json:"meters,omitempty"`
}
// Shelly charger implementation
type Shelly struct {
*request.Helper
log *util.Logger
uri string
gen int // Shelly api generation
channel int
standbypower float64
}
@ -60,16 +51,56 @@ func NewShellyFromConfig(other map[string]interface{}) (api.Charger, error) {
// NewShelly creates Shelly charger
func NewShelly(uri, user, password string, channel int, standbypower float64) (*Shelly, error) {
u, _ := url.Parse(uri)
if u.Host != "" {
uri = u.Host
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 {
return nil, err
}
c := &Shelly{
Helper: request.NewHelper(util.NewLogger("shelly")),
uri: fmt.Sprintf("http://%s:%s@%s", user, password, uri),
Helper: client,
log: log,
channel: channel,
standbypower: standbypower,
gen: resp.Gen,
}
c.Client.Transport = request.NewTripper(log, request.InsecureTransport())
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 != "" {
c.Client.Transport = basicauth.NewTransport(user, password, c.Client.Transport)
}
if resp.NumMeters == 0 {
return c, fmt.Errorf("%s (%s) gen1 missing power meter ", resp.Model, resp.Mac)
}
case 2:
// Shelly GEN 2 API
// https://shelly-api-docs.shelly.cloud/gen2/
c.uri = fmt.Sprintf("%s/rpc", util.DefaultScheme(uri, "https"))
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
@ -77,25 +108,46 @@ func NewShelly(uri, user, password string, channel int, standbypower float64) (*
// Enabled implements the api.Charger interface
func (c *Shelly) Enabled() (bool, error) {
var resp shellyRelayResponse
err := c.GetJSON(fmt.Sprintf("%s/relay/%d", c.uri, c.channel), &resp)
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
return resp.Ison, err
default:
var resp shelly.Gen2SwitchResponse
err := c.execGen2Cmd("Switch.GetStatus", false, &resp)
return resp.Output, err
}
}
// Enable implements the api.Charger interface
func (c *Shelly) Enable(enable bool) error {
var resp shellyRelayResponse
var err error
onoff := map[bool]string{true: "on", false: "off"}
err := c.GetJSON(fmt.Sprintf("%s/relay/%d?turn=%s", c.uri, c.channel, onoff[enable]), &resp)
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)
}
if err != nil {
return err
}
enabled, err := c.Enabled()
switch {
case err != nil:
return err
case enable && !resp.Ison:
return errors.New("switchOn failed")
case !enable && resp.Ison:
return errors.New("switchOff failed")
case enable != enabled:
return fmt.Errorf("switch %s failed", onoff[enable])
default:
return nil
}
@ -109,9 +161,10 @@ func (c *Shelly) MaxCurrent(current int64) error {
// Status implements the api.Charger interface
func (c *Shelly) Status() (api.ChargeStatus, error) {
power, err := c.CurrentPower()
if power > 0 {
if power > c.standbypower {
return api.StatusC, err
}
return api.StatusB, err
}
@ -119,18 +172,62 @@ var _ api.Meter = (*Shelly)(nil)
// CurrentPower implements the api.Meter interface
func (c *Shelly) CurrentPower() (float64, error) {
var resp shellyStatusResponse
err := c.GetJSON(fmt.Sprintf("%s/%s", c.uri, "status"), &resp)
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, power meter missing")
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
}
}
power := resp.Meters[c.channel].Power
// ignore standby power
if power < c.standbypower {
if power <= c.standbypower {
power = 0
}
return power, err
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)
}

45
charger/shelly/types.go Normal file
View file

@ -0,0 +1,45 @@
package shelly
// Shelly api homepage
// https://shelly-api-docs.shelly.cloud/#common-http-api
type DeviceInfo struct {
Gen int `json:"gen"`
Id string `json:"id"`
Model string `json:"model"`
Type string `json:"type"`
Mac string `json:"mac"`
Auth bool `json:"auth"`
AuthEn bool `json:"auth_en"`
NumMeters int `json:"num_meters"`
}
type Gen2RpcPost struct {
Id int `json:"id"`
On bool `json:"on"`
Src string `json:"src"`
Method string `json:"method"`
}
type Gen2SwitchResponse struct {
Output bool `json:"output"`
}
type Gen2Switch struct {
Apower float64
}
type Gen2StatusResponse struct {
Switch0 Gen2Switch `json:"switch:0"`
Switch1 Gen2Switch `json:"switch:1"`
Switch2 Gen2Switch `json:"switch:2"`
}
type Gen1SwitchResponse struct {
Ison bool
}
type Gen1StatusResponse struct {
Meters []struct {
Power float64
}
}

5
go.mod
View file

@ -39,6 +39,7 @@ require (
github.com/jeremywohl/flatten v1.0.1
github.com/jinzhu/copier v0.3.2
github.com/joeshaw/carwings v0.0.0-20210629130626-7ce4ec17db73
github.com/jpfielding/go-http-digest v0.0.0-20210817193807-9eb61c7a606e
github.com/juju/ansiterm v0.0.0-20210706145210-9283cdf370b5 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/koron/go-ssdp v0.0.2
@ -73,7 +74,7 @@ require (
github.com/volkszaehler/mbmd v0.0.0-20210808132733-8235dc7d1327
gitlab.com/bboehmke/sunny v0.15.0
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97 // indirect
golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d
golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914
golang.org/x/text v0.3.6
google.golang.org/genproto v0.0.0-20210722135532-667f2b7c528f // indirect
@ -83,3 +84,5 @@ require (
gopkg.in/sourcemap.v1 v1.0.5 // indirect
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b
)
replace github.com/jpfielding/go-http-digest => github.com/andig/go-http-digest v0.0.0-20211002213627-2c506d79719e

6
go.sum
View file

@ -57,6 +57,8 @@ github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRF
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/alvaroloes/enumer v1.1.2/go.mod h1:FxrjvuXoDAx9isTJrv4c+T410zFi0DtXIT0m65DJ+Wo=
github.com/andig/go-http-digest v0.0.0-20211002213627-2c506d79719e h1:3Z5Ec+mZ4Ngt/31FAsbG5ow5KaS0LbDKOwVrkRgQL7U=
github.com/andig/go-http-digest v0.0.0-20211002213627-2c506d79719e/go.mod h1:oLt6zF2euTyCg2Cxz01B3VC5SJcPpKpYnOAqvO+480s=
github.com/andig/gosunspec v0.0.0-20210511114617-aa30cf9b7a3f h1:X0eQydQuoC/R1ALXgDs9v+1UKWlgGbuMg+h8flc2BJk=
github.com/andig/gosunspec v0.0.0-20210511114617-aa30cf9b7a3f/go.mod h1:YkshK8WMzYn1iXAZzHUO75gIqhMSan2ctgBVtBkRIyA=
github.com/andybalholm/cascadia v1.2.0 h1:vuRCkM5Ozh/BfmsaTm26kbjm0mIOM3yS5Ek/F5h18aE=
@ -374,6 +376,7 @@ github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfC
github.com/joeshaw/carwings v0.0.0-20210629130626-7ce4ec17db73 h1:sSgFj1KNmZrvKid5G5333eZRRSsCNBPPSstz9mzcSoI=
github.com/joeshaw/carwings v0.0.0-20210629130626-7ce4ec17db73/go.mod h1:tB0OlpicmRVTL1Vksc5XRiYo+wkK2kl/GI7eGMIl6Rs=
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
github.com/jpfielding/gowirelog v0.0.0-20200123170752-df8f8dccb721/go.mod h1:R8b6Hefiy6MSxbJ8dbF9Gkaa/LwFh9meFV0i9hkR5i8=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
@ -761,8 +764,9 @@ golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLd
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985 h1:4CSI6oo7cOjJKajidEljs9h+uP0rRZBPPPhcCbj5mw8=
golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d h1:20cMwl2fHAzkJMEA+8J4JgqBQcQGzbisXo31MIeenXI=
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=

View file

@ -0,0 +1,24 @@
package basicauth
import (
"encoding/base64"
"net/http"
)
type transport struct {
header string
base http.RoundTripper
}
// NewTransport creates an http transport performing basic auth
func NewTransport(user, password string, base http.RoundTripper) http.RoundTripper {
return &transport{
header: "Basic " + base64.StdEncoding.EncodeToString([]byte(user+":"+password)),
base: base,
}
}
func (t *transport) RoundTrip(req *http.Request) (*http.Response, error) {
req.Header.Set("Authorization", t.header)
return t.base.RoundTrip(req)
}

1113
util/go.sum Normal file

File diff suppressed because it is too large Load diff

View file

@ -4,6 +4,7 @@ import (
"fmt"
"net"
"net/url"
"strings"
)
// DefaultPort appends given port to connection if not specified
@ -19,6 +20,9 @@ func DefaultPort(conn string, port int) string {
func DefaultScheme(uri string, scheme string) string {
u, err := url.Parse(uri)
if err != nil {
if strings.HasSuffix(err.Error(), "first path segment in URL cannot contain colon") {
return fmt.Sprintf("%s://%s", scheme, uri)
}
return uri
}