Add Shelly-NG 3nd gen devices (#1682)
This commit is contained in:
parent
8facebd7ad
commit
cfbb2cbca7
7 changed files with 1330 additions and 40 deletions
|
|
@ -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
45
charger/shelly/types.go
Normal 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
5
go.mod
|
|
@ -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
6
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
24
util/basicauth/transport.go
Normal file
24
util/basicauth/transport.go
Normal 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
1113
util/go.sum
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue