From 21b76d5627d63afef17754ef7d3f7b79b9c55ad2 Mon Sep 17 00:00:00 2001 From: Markus Thierolf <77847348+thierolm@users.noreply.github.com> Date: Fri, 18 Apr 2025 17:01:46 +0200 Subject: [PATCH] Shelly: add usage and caching (BC) (#20570) --- charger/shelly.go | 32 ++- charger/shelly_decorators.go | 111 +++++++ meter/shelly.go | 58 +++- meter/shelly/connection.go | 81 ++---- meter/shelly/energymeter.go | 71 ----- meter/shelly/gen1.go | 110 +++++++ meter/shelly/gen1_test.go | 9 +- meter/shelly/gen2.go | 271 ++++++++++++++++-- meter/shelly/gen2_test.go | 72 ++--- meter/shelly/switch.go | 147 ---------- meter/shelly_decorators.go | 111 +++++++ meter/shelly_energymeter.go | 33 --- templates/definition/meter/shelly-1pm.yaml | 1 + .../definition/meter/shelly-pro-3em.yaml | 3 +- tests/simulator/api.js | 11 +- 15 files changed, 714 insertions(+), 407 deletions(-) create mode 100644 charger/shelly_decorators.go delete mode 100644 meter/shelly/energymeter.go delete mode 100644 meter/shelly/switch.go create mode 100644 meter/shelly_decorators.go delete mode 100644 meter/shelly_energymeter.go diff --git a/charger/shelly.go b/charger/shelly.go index e992bb425..018b9f923 100644 --- a/charger/shelly.go +++ b/charger/shelly.go @@ -2,6 +2,7 @@ package charger import ( "fmt" + "time" "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/meter/shelly" @@ -10,7 +11,7 @@ import ( // Shelly charger implementation type Shelly struct { - conn *shelly.Switch + conn *shelly.Connection *switchSocket } @@ -18,33 +19,50 @@ func init() { registry.Add("shelly", NewShellyFromConfig) } +//go:generate go tool decorate -f decorateShelly -b *Shelly -r api.Charger -t "api.PhaseVoltages,Voltages,func() (float64, float64, float64, error)" -t "api.PhaseCurrents,Currents,func() (float64, float64, float64, error)" -t "api.PhasePowers,Powers,func() (float64, float64, float64, error)" + // NewShellyFromConfig creates a Shelly charger from generic config -func NewShellyFromConfig(other map[string]interface{}) (api.Charger, error) { - var cc struct { +func NewShellyFromConfig(other map[string]any) (api.Charger, error) { + cc := struct { embed `mapstructure:",squash"` URI string User string Password string Channel int StandbyPower float64 + Cache time.Duration + }{ + Cache: time.Second, } if err := util.DecodeOther(other, &cc); err != nil { return nil, err } - return NewShelly(cc.embed, cc.URI, cc.User, cc.Password, cc.Channel, cc.StandbyPower) + c, err := NewShelly(cc.embed, cc.URI, cc.User, cc.Password, cc.Channel, cc.StandbyPower, cc.Cache) + if err != nil { + return nil, err + } + + var vol, cur, pow func() (float64, float64, float64, error) + if phases, ok := c.conn.Generation.(shelly.Phases); ok { + vol = phases.Voltages + cur = phases.Currents + pow = phases.Powers + } + + return decorateShelly(c, vol, cur, pow), nil } // NewShelly creates Shelly charger -func NewShelly(embed embed, uri, user, password string, channel int, standbypower float64) (*Shelly, error) { - conn, err := shelly.NewConnection(uri, user, password, channel) +func NewShelly(embed embed, uri, user, password string, channel int, standbypower float64, cache time.Duration) (*Shelly, error) { + conn, err := shelly.NewConnection(uri, user, password, channel, cache) if err != nil { return nil, err } c := &Shelly{ - conn: shelly.NewSwitch(conn), + conn: conn, } c.switchSocket = NewSwitchSocket(&embed, c.Enabled, c.conn.CurrentPower, standbypower) diff --git a/charger/shelly_decorators.go b/charger/shelly_decorators.go new file mode 100644 index 000000000..0aa4820ea --- /dev/null +++ b/charger/shelly_decorators.go @@ -0,0 +1,111 @@ +package charger + +// Code generated by github.com/evcc-io/evcc/cmd/tools/decorate.go. DO NOT EDIT. + +import ( + "github.com/evcc-io/evcc/api" +) + +func decorateShelly(base *Shelly, phaseVoltages func() (float64, float64, float64, error), phaseCurrents func() (float64, float64, float64, error), phasePowers func() (float64, float64, float64, error)) api.Charger { + switch { + case phaseCurrents == nil && phaseVoltages == nil: + return base + + case phaseCurrents == nil && phaseVoltages != nil: + return &struct { + *Shelly + api.PhaseVoltages + }{ + Shelly: base, + PhaseVoltages: &decorateShellyPhaseVoltagesImpl{ + phaseVoltages: phaseVoltages, + }, + } + + case phaseCurrents != nil && phasePowers == nil && phaseVoltages == nil: + return &struct { + *Shelly + api.PhaseCurrents + }{ + Shelly: base, + PhaseCurrents: &decorateShellyPhaseCurrentsImpl{ + phaseCurrents: phaseCurrents, + }, + } + + case phaseCurrents != nil && phasePowers == nil && phaseVoltages != nil: + return &struct { + *Shelly + api.PhaseCurrents + api.PhaseVoltages + }{ + Shelly: base, + PhaseCurrents: &decorateShellyPhaseCurrentsImpl{ + phaseCurrents: phaseCurrents, + }, + PhaseVoltages: &decorateShellyPhaseVoltagesImpl{ + phaseVoltages: phaseVoltages, + }, + } + + case phaseCurrents != nil && phasePowers != nil && phaseVoltages == nil: + return &struct { + *Shelly + api.PhaseCurrents + api.PhasePowers + }{ + Shelly: base, + PhaseCurrents: &decorateShellyPhaseCurrentsImpl{ + phaseCurrents: phaseCurrents, + }, + PhasePowers: &decorateShellyPhasePowersImpl{ + phasePowers: phasePowers, + }, + } + + case phaseCurrents != nil && phasePowers != nil && phaseVoltages != nil: + return &struct { + *Shelly + api.PhaseCurrents + api.PhasePowers + api.PhaseVoltages + }{ + Shelly: base, + PhaseCurrents: &decorateShellyPhaseCurrentsImpl{ + phaseCurrents: phaseCurrents, + }, + PhasePowers: &decorateShellyPhasePowersImpl{ + phasePowers: phasePowers, + }, + PhaseVoltages: &decorateShellyPhaseVoltagesImpl{ + phaseVoltages: phaseVoltages, + }, + } + } + + return nil +} + +type decorateShellyPhaseCurrentsImpl struct { + phaseCurrents func() (float64, float64, float64, error) +} + +func (impl *decorateShellyPhaseCurrentsImpl) Currents() (float64, float64, float64, error) { + return impl.phaseCurrents() +} + +type decorateShellyPhasePowersImpl struct { + phasePowers func() (float64, float64, float64, error) +} + +func (impl *decorateShellyPhasePowersImpl) Powers() (float64, float64, float64, error) { + return impl.phasePowers() +} + +type decorateShellyPhaseVoltagesImpl struct { + phaseVoltages func() (float64, float64, float64, error) +} + +func (impl *decorateShellyPhaseVoltagesImpl) Voltages() (float64, float64, float64, error) { + return impl.phaseVoltages() +} diff --git a/meter/shelly.go b/meter/shelly.go index 67ac8f8a8..3dd2f54d5 100644 --- a/meter/shelly.go +++ b/meter/shelly.go @@ -1,33 +1,83 @@ package meter import ( + "math" + "strings" + "time" + "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/meter/shelly" "github.com/evcc-io/evcc/util" ) +// Shelly meter considering usage +type Shelly struct { + shelly.Connection + usage string +} + // Shelly meter implementation func init() { registry.Add("shelly", NewShellyFromConfig) } +//go:generate go tool decorate -f decorateShelly -b *Shelly -r api.Meter -t "api.PhaseVoltages,Voltages,func() (float64, float64, float64, error)" -t "api.PhaseCurrents,Currents,func() (float64, float64, float64, error)" -t "api.PhasePowers,Powers,func() (float64, float64, float64, error)" + // NewShellyFromConfig creates a Shelly charger from generic config -func NewShellyFromConfig(other map[string]interface{}) (api.Meter, error) { - var cc struct { +func NewShellyFromConfig(other map[string]any) (api.Meter, error) { + cc := struct { URI string User string Password string Channel int + Usage string + Cache time.Duration + }{ + Cache: time.Second, } if err := util.DecodeOther(other, &cc); err != nil { return nil, err } - conn, err := shelly.NewConnection(cc.URI, cc.User, cc.Password, cc.Channel) + c, err := NewShelly(cc.URI, cc.User, cc.Password, strings.ToLower(cc.Usage), cc.Channel, cc.Cache) if err != nil { return nil, err } - return shelly.NewSwitch(conn), nil + var vol, cur, pow func() (float64, float64, float64, error) + if phases, ok := c.Connection.Generation.(shelly.Phases); ok { + vol = phases.Voltages + cur = phases.Currents + pow = phases.Powers + } + + return decorateShelly(c, vol, cur, pow), nil +} + +// NewShelly creates Shelly meter +func NewShelly(uri, user, password, usage string, channel int, cache time.Duration) (*Shelly, error) { + conn, err := shelly.NewConnection(uri, user, password, channel, cache) + if err != nil { + return nil, err + } + c := &Shelly{ + Connection: *conn, + usage: usage, + } + return c, nil +} + +var _ api.Meter = (*Shelly)(nil) + +// CurrentPower implements the api.Meter interface +func (c *Shelly) CurrentPower() (float64, error) { + power, err := c.Connection.CurrentPower() + if err != nil { + return 0, err + } + if c.usage == "pv" { + power = math.Abs(power) + } + return power, nil } diff --git a/meter/shelly/connection.go b/meter/shelly/connection.go index 4024cb86f..01dc0a3cf 100644 --- a/meter/shelly/connection.go +++ b/meter/shelly/connection.go @@ -3,27 +3,34 @@ package shelly import ( "errors" "fmt" - "net/http" "strings" + "time" "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" ) +type Generation interface { + CurrentPower() (float64, error) + Enabled() (bool, error) + Enable(bool) error + TotalEnergy() (float64, error) +} + +type Phases interface { + Currents() (float64, float64, float64, error) + Voltages() (float64, float64, float64, error) + Powers() (float64, float64, float64, error) +} + // Connection is the Shelly connection type Connection struct { - *request.Helper - uri string - channel int - gen int // Shelly api generation - model string // Shelly device type - profile string // Shelly device profile + Generation } // NewConnection creates a new Shelly device connection. -func NewConnection(uri, user, password string, channel int) (*Connection, error) { +func NewConnection(uri, user, password string, channel int, cache time.Duration) (*Connection, error) { if uri == "" { return nil, errors.New("missing uri") } @@ -31,13 +38,14 @@ func NewConnection(uri, user, password string, channel int) (*Connection, error) for _, suffix := range []string{"/", "/rcp", "/shelly"} { uri = strings.TrimSuffix(uri, suffix) } + uri = util.DefaultScheme(uri, "http") 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 { + if err := client.GetJSON(fmt.Sprintf("%s/shelly", uri), &resp); err != nil { return nil, err } @@ -45,61 +53,30 @@ func NewConnection(uri, user, password string, channel int) (*Connection, error) return nil, fmt.Errorf("%s (%s) missing user/password", resp.Model, resp.Mac) } - conn := &Connection{ - Helper: client, - uri: util.DefaultScheme(uri, "http"), - channel: channel, - gen: resp.Gen, - model: strings.Split(resp.Type+resp.Model, "-")[0], - profile: resp.Profile, - } - - conn.Client.Transport = request.NewTripper(log, transport.Insecure()) - // Set default profile to "monophase" if not provided if resp.Profile == "" { resp.Profile = "monophase" } - switch conn.gen { - case 0, 1: + model := strings.Split(resp.Type+resp.Model, "-")[0] + + client.Transport = request.NewTripper(log, transport.Insecure()) + + var gen Generation + if resp.Gen < 2 { // Shelly GEN 1 API // https://shelly-api-docs.shelly.cloud/gen1/#shelly-family-overview if user != "" { log.Redact(transport.BasicAuthHeader(user, password)) - conn.Client.Transport = transport.BasicAuth(user, password, conn.Client.Transport) } - case 2, 3: + gen = newGen1(client, uri, model, channel, user, password, cache) + } else { // Shelly GEN 2+ API // https://shelly-api-docs.shelly.cloud/gen2/ - conn.uri += "/rpc" - 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) + gen = newGen2(client, uri, model, resp.Profile, channel, user, password, cache) } + conn := &Connection{gen} + return conn, nil } - -// 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/meter/shelly/energymeter.go b/meter/shelly/energymeter.go deleted file mode 100644 index 6de49cb2a..000000000 --- a/meter/shelly/energymeter.go +++ /dev/null @@ -1,71 +0,0 @@ -package shelly - -import "github.com/evcc-io/evcc/api" - -type EnergyMeter struct { - conn *Connection -} - -func NewEnergyMeter(conn *Connection) *EnergyMeter { - res := &EnergyMeter{ - conn: conn, - } - - return res -} - -// CurrentPower implements the api.Meter interface -func (sh *EnergyMeter) CurrentPower() (float64, error) { - var res Gen2StatusResponse - if err := sh.conn.execGen2Cmd("EM.GetStatus", false, &res); err != nil { - return 0, err - } - - return res.TotalPower, nil -} - -// TotalEnergy implements the api.Meter interface -func (sh *EnergyMeter) TotalEnergy() (float64, error) { - var res Gen2EmDataStatusResponse - if err := sh.conn.execGen2Cmd("EMData.GetStatus", false, &res); err != nil { - return 0, err - } - - return res.TotalEnergy / 1000, nil -} - -var _ api.PhaseCurrents = (*EnergyMeter)(nil) - -// Currents implements the api.PhaseCurrents interface -func (sh *EnergyMeter) Currents() (float64, float64, float64, error) { - var res Gen2StatusResponse - if err := sh.conn.execGen2Cmd("EM.GetStatus", false, &res); err != nil { - return 0, 0, 0, err - } - - return res.CurrentA, res.CurrentB, res.CurrentC, nil -} - -var _ api.PhaseVoltages = (*EnergyMeter)(nil) - -// Voltages implements the api.PhaseVoltages interface -func (sh *EnergyMeter) Voltages() (float64, float64, float64, error) { - var res Gen2StatusResponse - if err := sh.conn.execGen2Cmd("EM.GetStatus", false, &res); err != nil { - return 0, 0, 0, err - } - - return res.VoltageA, res.VoltageB, res.VoltageC, nil -} - -var _ api.PhasePowers = (*EnergyMeter)(nil) - -// Powers implements the api.PhasePowers interface -func (sh *EnergyMeter) Powers() (float64, float64, float64, error) { - var res Gen2StatusResponse - if err := sh.conn.execGen2Cmd("EM.GetStatus", false, &res); err != nil { - return 0, 0, 0, err - } - - return res.PowerA, res.PowerB, res.PowerC, nil -} diff --git a/meter/shelly/gen1.go b/meter/shelly/gen1.go index 73b328326..d4ed9d445 100644 --- a/meter/shelly/gen1.go +++ b/meter/shelly/gen1.go @@ -1,5 +1,16 @@ package shelly +import ( + "errors" + "fmt" + "strings" + "time" + + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" + "github.com/evcc-io/evcc/util/transport" +) + // Gen1API endpoint reference: https://shelly-api-docs.shelly.cloud/gen1/#shelly-family-overview type Gen1SwitchResponse struct { @@ -23,3 +34,102 @@ type Gen1Status struct { Total_Returned float64 } } + +var _ Generation = (*gen1)(nil) + +type gen1 struct { + *request.Helper + uri string + channel int + model string + status util.Cacheable[Gen1Status] +} + +// newGen1 initializes the connection to the shelly gen1 api and sets up the cached gen1Status +func newGen1(client *request.Helper, uri, model string, channel int, user, password string, cache time.Duration) *gen1 { + if user != "" { + client.Transport = transport.BasicAuth(user, password, client.Transport) + } + + c := &gen1{ + Helper: client, + uri: uri, + model: model, + channel: channel, + } + + // Cached gen1Status + c.status = util.ResettableCached(func() (Gen1Status, error) { + var res Gen1Status + if err := c.GetJSON(fmt.Sprintf("%s/status", uri), &res); err != nil { + return Gen1Status{}, err + } + return res, nil + }, cache) + + return c +} + +func (c *gen1) CurrentPower() (float64, error) { + var power float64 + res, err := c.status.Get() + if err != nil { + return 0, err + } + + switch { + case c.channel < len(res.Meters): + power = res.Meters[c.channel].Power + case c.channel < len(res.EMeters): + power = res.EMeters[c.channel].Power + default: + return 0, errors.New("invalid channel, missing power meter") + } + + return power, nil +} + +func (c *gen1) Enabled() (bool, error) { + var res Gen1SwitchResponse + uri := fmt.Sprintf("%s/relay/%d", c.uri, c.channel) + err := c.GetJSON(uri, &res) + return res.Ison, err +} + +func (c *gen1) Enable(enable bool) error { + onoff := map[bool]string{true: "on", false: "off"} + + c.status.Reset() + + var res Gen1SwitchResponse + uri := fmt.Sprintf("%s/relay/%d?turn=%s", c.uri, c.channel, onoff[enable]) + return c.GetJSON(uri, &res) +} + +func (c *gen1) TotalEnergy() (float64, error) { + var energy float64 + res, err := c.status.Get() + if err != nil { + return 0, err + } + + switch { + case c.channel < len(res.Meters): + energy = res.Meters[c.channel].Total - res.Meters[c.channel].Total_Returned + case c.channel < len(res.EMeters): + energy = res.EMeters[c.channel].Total - res.EMeters[c.channel].Total_Returned + default: + return 0, errors.New("invalid channel, missing power meter") + } + + return c.energy(energy) / 1000, nil +} + +// gen1Energy in kWh +func (c *gen1) energy(energy float64) float64 { + // Gen 1 Shelly EM devices are providing Watt hours, Gen 1 Shelly PM devices are providing Watt minutes + if !strings.Contains(c.model, "EM") { + energy /= 60 + } + return energy +} diff --git a/meter/shelly/gen1_test.go b/meter/shelly/gen1_test.go index ac1f50fa4..7f9dc6b21 100644 --- a/meter/shelly/gen1_test.go +++ b/meter/shelly/gen1_test.go @@ -17,7 +17,8 @@ func TestUnmarshalGen1Status(t *testing.T) { jsonstr := `{"wifi_sta":{"connected":true,"ssid":"XXXX-WLAN","ip":"192.168.178.XXX","rssi":-54},"cloud":{"enabled":false,"connected":false},"mqtt":{"connected":false},"time":"17:59","unixtime":1676134770,"serial":2437,"has_update":true,"mac":"84CCA8XXXXXXX","cfg_changed_cnt":1,"actions_stats":{"skipped":0},"relays":[{"ison":false,"has_timer":false,"timer_started":0,"timer_duration":0,"timer_remaining":0,"overpower":false,"source":"http"}],"meters":[{"power":4711.12,"overpower":0.00,"is_valid":true,"timestamp":1676138370,"counters":[0.000, 0.000, 0.000],"total":6472513}],"inputs":[{"input":0,"event":"","event_cnt":0}],"temperature":16.79,"overtemperature":false,"tmp":{"tC":16.79,"tF":62.22, "is_valid":true},"temperature_status":"Normal","ext_sensors":{},"ext_temperature":{},"ext_humidity":{},"update":{"status":"pending","has_update":true,"new_version":"20221108-153925/v1.12.1-1PM-fix-g2821131","old_version":"20220209-094317/v1.11.8-g8c7bb8d"},"ram_total":50456,"ram_free":37056,"fs_size":233681,"fs_free":149094,"uptime":17284290}` require.NoError(t, json.Unmarshal([]byte(jsonstr), &res)) - assert.Equal(t, 107875.21666666666, gen1Energy("SHSW-PM", res.Meters[0].Total)) + g := &gen1{model: "SHSW-PM"} + assert.Equal(t, 107875.21666666666, g.energy(res.Meters[0].Total)) assert.Equal(t, 4711.12, res.Meters[0].Power) } @@ -28,7 +29,8 @@ func TestUnmarshalGen1Status(t *testing.T) { jsonstr := `{"wifi_sta":{"connected":true,"ssid":"XXXX-WLAN","ip":"192.168.178.XXX","rssi":-57},"cloud":{"enabled":false,"connected":false},"mqtt":{"connected":false},"time":"19:25","unixtime":1676139913,"serial":959,"has_update":true,"mac":"E8DB8XXXXXX","cfg_changed_cnt":1,"actions_stats":{"skipped":0},"relays":[{"ison":false,"has_timer":false,"timer_started":0,"timer_duration":0,"timer_remaining":0,"source":"timer"}],"meters":[{"power":81.5,"is_valid":true}],"inputs":[{"input":0,"event":"","event_cnt":0}],"ext_sensors":{},"ext_temperature":{},"ext_humidity":{},"update":{"status":"pending","has_update":true,"new_version":"20221027-091427/v1.12.1-ga9117d3","old_version":"20211109-124958/v1.11.7-g682a0db"},"ram_total":50880,"ram_free":38796,"fs_size":233681,"fs_free":151102,"uptime":20319391}` require.NoError(t, json.Unmarshal([]byte(jsonstr), &res)) - assert.Equal(t, 0.0, gen1Energy("SHSW-1", res.Meters[0].Total)) + g := &gen1{model: "SHSW-1"} + assert.Equal(t, 0.0, g.energy(res.Meters[0].Total)) assert.Equal(t, 81.5, res.Meters[0].Power) } @@ -39,7 +41,8 @@ func TestUnmarshalGen1Status(t *testing.T) { jsonstr := `{"wifi_sta":{"connected":true,"ssid":"XXXX","ip":"192.168.178.XXX","rssi":-55},"cloud":{"enabled":false,"connected":false},"mqtt":{"connected":false},"time":"11:16","unixtime":1676110566,"serial":21580,"has_update":false,"mac":"C45BXXXXX","cfg_changed_cnt":0,"actions_stats":{"skipped":0},"relays":[{"ison":false,"has_timer":false,"timer_started":0,"timer_duration":0,"timer_remaining":0,"overpower":false,"is_valid":true,"source":"input"}],"emeters":[{"power":-620.34,"reactive":714.48,"pf":-0.66,"voltage":235.68,"is_valid":true,"total":401472.9,"total_returned":653673.7},{"power":0.00,"reactive":0.00,"pf":0.00,"voltage":235.68,"is_valid":true,"total":173411.3,"total_returned":294.2}],"update":{"status":"idle","has_update":false,"new_version":"20221027-105518/v1.12.1-ga9117d3","old_version":"20221027-105518/v1.12.1-ga9117d3"},"ram_total":51072,"ram_free":35660,"fs_size":233681,"fs_free":156373,"uptime":2226140}` require.NoError(t, json.Unmarshal([]byte(jsonstr), &res)) - assert.Equal(t, 401472.9, gen1Energy("SHEM", res.EMeters[0].Total)) + g := &gen1{model: "SHEM"} + assert.Equal(t, 401472.9, g.energy(res.EMeters[0].Total)) assert.Equal(t, -620.34, res.EMeters[0].Power) } } diff --git a/meter/shelly/gen2.go b/meter/shelly/gen2.go index 639105c4a..5264880b4 100644 --- a/meter/shelly/gen2.go +++ b/meter/shelly/gen2.go @@ -1,5 +1,17 @@ package shelly +import ( + "fmt" + "net/http" + "slices" + "strconv" + "time" + + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" + "github.com/jpfielding/go-http-digest/pkg/digest" +) + // Gen2API endpoint reference: https://shelly-api-docs.shelly.cloud/gen2/ type Gen2RpcPost struct { @@ -51,32 +63,239 @@ type Gen2EM1Data struct { TotalActRetEnergy float64 `json:"total_act_ret_energy"` } -type Gen2StatusResponse struct { - Switch0 Gen2SwitchStatus `json:"switch:0"` - Switch1 Gen2SwitchStatus `json:"switch:1"` - Switch2 Gen2SwitchStatus `json:"switch:2"` - Pm0 Gen2SwitchStatus `json:"pm1:0"` - Pm1 Gen2SwitchStatus `json:"pm2:1"` - Pm2 Gen2SwitchStatus `json:"pm3:2"` - // additional shelly Pro EM meter JSON response - TotalPower float64 `json:"total_act_power"` - CurrentA float64 `json:"a_current"` - CurrentB float64 `json:"b_current"` - CurrentC float64 `json:"c_current"` - VoltageA float64 `json:"a_voltage"` - VoltageB float64 `json:"b_voltage"` - VoltageC float64 `json:"c_voltage"` - PowerA float64 `json:"a_act_power"` - PowerB float64 `json:"b_act_power"` - PowerC float64 `json:"c_act_power"` - Em0 Gen2EM1Status `json:"em1:0"` - Em1 Gen2EM1Status `json:"em1:1"` - Em2 Gen2EM1Status `json:"em1:2"` - Em0Data Gen2EM1Data `json:"em1data:0"` - Em1Data Gen2EM1Data `json:"em1data:1"` - Em2Data Gen2EM1Data `json:"em1data:2"` +var _ Generation = (*gen2)(nil) + +type gen2 struct { + *request.Helper + uri string + channel int + model string + profile string + switchstatus util.Cacheable[Gen2SwitchStatus] + em1status func() (Gen2EM1Status, error) + em1data func() (Gen2EM1Data, error) + emstatus func() (Gen2EMStatus, error) + emdata func() (Gen2EMData, error) } -type Gen2EmDataStatusResponse struct { - TotalEnergy float64 `json:"total_act"` +func apiCall[T any](c *gen2, api string) func() (T, error) { + return func() (T, error) { + var res T + if err := c.execCmd(fmt.Sprintf("%s?id=%d", api, c.channel), false, &res); err != nil { + return res, err + } + return res, nil + } +} + +// gen2InitApi initializes the connection to the shelly gen2+ api and sets up the cached gen2SwitchStatus, gen2EM1Status and gen2EMStatus +func newGen2(helper *request.Helper, uri, model, profile string, channel int, user, password string, cache time.Duration) *gen2 { + c := &gen2{ + Helper: helper, + uri: uri, + channel: channel, + model: model, + profile: profile, + } + + // 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) + } + + c.switchstatus = util.ResettableCached(apiCall[Gen2SwitchStatus](c, "Switch.GetStatus"), cache) + c.em1status = util.Cached(apiCall[Gen2EM1Status](c, "EM1.GetStatus"), cache) + c.em1data = util.Cached(apiCall[Gen2EM1Data](c, "EM1Data.GetStatus"), cache) + c.emstatus = util.Cached(apiCall[Gen2EMStatus](c, "EM.GetStatus"), cache) + c.emdata = util.Cached(apiCall[Gen2EMData](c, "EMData.GetStatus"), cache) + + return c +} + +// execCmd executes a shelly api gen2+ command and provides the response +func (c *gen2) execCmd(method string, enable bool, res any) 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: 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) +} + +// CurrentPower implements the api.Meter interface +func (c *gen2) CurrentPower() (float64, error) { + switch { + // Endpoint Switch.GetStatus + case c.hasSwitchEndpoint(): + res, err := c.switchstatus.Get() + return res.Apower, err + + // Endpoint EM1.GetStatus + case c.hasEMEndpoint() && c.profile == "monophase": + res, err := c.em1status() + return res.ActPower, err + + // Endpoint EM.GetStatus + case c.hasEMEndpoint() && c.profile == "triphase": + res, err := c.emstatus() + return res.TotalActPower, err + + default: + return 0, fmt.Errorf("unknown shelly model: %s", c.model) + } +} + +// Gen2Enabled implements the Gen2 api.Charger interface +func (c *gen2) Enabled() (bool, error) { + if c.hasSwitchEndpoint() { + res, err := c.switchstatus.Get() + return res.Output, err + } + + return false, fmt.Errorf("unknown shelly model: %s", c.model) +} + +// Gen2Enable implements the api.Charger interface +func (c *gen2) Enable(enable bool) error { + var res Gen2SwitchStatus + c.switchstatus.Reset() + return c.execCmd("Switch.Set?id="+strconv.Itoa(c.channel), enable, &res) +} + +// TotalEnergy implements the api.Meter interface +func (c *gen2) TotalEnergy() (float64, error) { + switch { + case c.hasSwitchEndpoint(): + res, err := c.switchstatus.Get() + return res.Aenergy.Total / 1000, err + + case c.hasEMEndpoint() && c.profile == "monophase": + res, err := c.em1data() + return res.TotalActEnergy / 1000, err + + case c.hasEMEndpoint() && c.profile == "triphase": + res, err := c.emdata() + return res.TotalAct / 1000, err + + default: + return 0, fmt.Errorf("unknown shelly model: %s", c.model) + } +} + +// Currents implements the api.PhaseCurrents interface +func (c *gen2) Currents() (float64, float64, float64, error) { + switch { + case c.hasSwitchEndpoint(): + res, err := c.switchstatus.Get() + return res.Current, 0, 0, err + + case c.hasEMEndpoint() && c.profile == "monophase": + res, err := c.em1status() + return res.Current, 0, 0, err + + case c.hasEMEndpoint() && c.profile == "triphase": + res, err := c.emstatus() + return res.ACurrent, res.BCurrent, res.CCurrent, err + + default: + return 0, 0, 0, fmt.Errorf("unknown shelly model: %s", c.model) + } +} + +// Voltages implements the api.PhaseVoltages interface +func (c *gen2) Voltages() (float64, float64, float64, error) { + switch { + case c.hasSwitchEndpoint(): + res, err := c.switchstatus.Get() + return res.Voltage, 0, 0, err + + case c.hasEMEndpoint() && c.profile == "monophase": + res, err := c.em1status() + return res.Voltage, 0, 0, err + + case c.hasEMEndpoint() && c.profile == "triphase": + res, err := c.emstatus() + return res.AVoltage, res.BVoltage, res.CVoltage, err + + default: + return 0, 0, 0, fmt.Errorf("unknown shelly model: %s", c.model) + } +} + +// Powers implements the api.PhasePowers interface +func (c *gen2) Powers() (float64, float64, float64, error) { + switch { + case c.hasSwitchEndpoint(): + res, err := c.switchstatus.Get() + return res.Apower, 0, 0, err + + case c.hasEMEndpoint() && c.profile == "monophase": + res, err := c.em1status() + return res.ActPower, 0, 0, err + + case c.hasEMEndpoint() && c.profile == "triphase": + res, err := c.emstatus() + return res.AActPower, res.BActPower, res.CActPower, err + + default: + return 0, 0, 0, fmt.Errorf("unknown shelly model: %s", c.model) + } +} + +// Gen2+ models using Switch.GetStatus endpoint https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Switch#switchgetstatus-example +func (c *gen2) hasSwitchEndpoint() bool { + // Generation 2 Devices (Plus Series): + // - SNSW-001X16EU: Shelly Plus 1 with 1x relay + // - SNSW-001P16EU: Shelly Plus 1PM with 1x relay + power meter + // - SNSW-002P16EU: Shelly Plus 2PM with 2x relay + power meter + // - SNPL-00112EU: Shelly Plus Plug S (EU) + // - SNPL-00110IT: Shelly Plus Plug S (Italy) + // - SNPL-00112UK: Shelly Plus Plug S (UK) + // - SNPL-00116US: Shelly Plus Plug S (US) + // Generation 2 Devices (Pro Series - Hutschiene): + // - SPSW-001XE16EU: Shelly Pro 1 with 1x relay + // - SPSW-001PE16EU: Shelly Pro 1 PM with 1x relay + power meter + // - SPSW-002XE16EU: Shelly Pro 2 with 2x relay + // - SPSW-002PE16EU: Shelly Pro 2 PM with 2x relay + power meter + // - SPSW-003XE16EU: Shelly Pro 3 with 3x relay + // - SPSW-004PE16EU: Shelly Pro 4 PM with 4x relay + power meter + // Generation 3 Devices: + // - S3SW-001P16EU: Shelly 1PM Gen3 with 1x relay + power meter + // Generation 4 Devices: + // - S4SW-001X8EU: Shelly 1 Mini Gen4 with 1x relay + // - S4SW-001P8EU: Shelly 1PM Mini Gen4 with 1x relay + power meter + // - S4SW-001P16EU: Shelly 1PM Gen4 with 1x relay + power meter + switchModels := []string{"SNSW", "SNPL", "SPSW", "S3SW", "S4SW"} + return slices.Contains(switchModels, c.model) +} + +// Gen2+ models using EM1.GetStatus endpoint for power and EM1Data.GetStatus for energy +// https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/EM1#em1getstatus-example +// https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/EM1Data#em1datagetstatus-example +func (c *gen2) hasEMEndpoint() bool { + // Generation 2 Devices (Pro Series - Hutschiene): + // - SPEM-002CEBEU50: Shelly Pro EM 50 with 1x relay + 2x energy meter + // - SPEM-003CEBEU120: Shelly Pro 3EM with 3x energy meter + // - SPEM-003CEBEU63: Shelly Pro 3EM-3CT63 with 3x energy meter + // Generation 3 Devices: + // - S3EM-002CXCEU: Shelly EM Gen 3 with 1x relay + 2x energy meter + // - S3EM-001XCEU: Shelly 3EM Gen 3 with 3x energy meter + // - S3EM-003CXCEU63: Shelly 3EM Gen 3 with 3x energy meter + // Generation 4 Devices: + // - S4EM-001PXCEU16: Shelly EM Mini Gen4 with power meter + em1Models := []string{"SPEM", "S3EM", "S4EM"} + return slices.Contains(em1Models, c.model) } diff --git a/meter/shelly/gen2_test.go b/meter/shelly/gen2_test.go index efeb1de8c..b12ac0c8c 100644 --- a/meter/shelly/gen2_test.go +++ b/meter/shelly/gen2_test.go @@ -11,70 +11,30 @@ import ( // Test Gen2+ status responses func TestUnmarshalGen2StatusResponse(t *testing.T) { { - // Shelly Pro 1PM channel 0 (1) - var res Gen2StatusResponse - - jsonstr := `{"ble":{},"cloud":{"connected":true},"eth":{"ip":null},"input:0":{"id":0,"state":false},"input:1":{"id":1,"state":false},"mqtt":{"connected":false},"switch:0":{"id":0, "source":"HTTP", "output":false, "apower":47.11, "voltage":232.0, "current":0.000, "pf":0.00, "aenergy":{"total":5.125,"by_minute":[0.000,0.000,0.000],"minute_ts":1675718520},"temperature":{"tC":25.3, "tF":77.5}},"sys":{"mac":"30C6F78BB4D8","restart_required":false,"time":"22:22","unixtime":1675718522,"uptime":45070,"ram_size":234204,"ram_free":137716,"fs_size":524288,"fs_free":172032,"cfg_rev":13,"kvs_rev":1,"schedule_rev":0,"webhook_rev":0,"available_updates":{"beta":{"version":"0.13.0-beta3"}}},"wifi":{"sta_ip":"192.168.178.64","status":"got ip","ssid":"***","rssi":-62},"ws":{"connected":false}}` + // Switch.GetStatus Endpoint + var res Gen2SwitchStatus + jsonstr := `{"id":0, "source":"HTTP", "output":false, "apower":47.11, "voltage":232.0, "current":0.000, "pf":0.00, "aenergy":{"total":5.125,"by_minute":[0.000,0.000,0.000],"minute_ts":1675718520},"temperature":{"tC":25.3, "tF":77.5}}` require.NoError(t, json.Unmarshal([]byte(jsonstr), &res)) - - assert.Equal(t, 5.125, res.Switch0.Aenergy.Total) - assert.Equal(t, 47.11, res.Switch0.Apower) + assert.Equal(t, 5.125, res.Aenergy.Total) + assert.Equal(t, 47.11, res.Apower) } { - // Shelly PM Mini Gen3 channel 0 (1) - var res Gen2StatusResponse - - jsonstr := `{"ble":{},"cloud":{"connected":true},"mqtt":{"connected":false},"pm1:0":{"id":0, "voltage":239.9, "current":7.434, "apower":1780.1 ,"freq":50.1,"aenergy":{"total":3551.682,"by_minute":[15234.772,29611.247,29825.821],"minute_ts":1719917850},"ret_aenergy":{"total":0.000,"by_minute":[0.000,0.000,0.000],"minute_ts":1719917850}},"sys":{"mac":"84FCE638D818","restart_required":false,"time":"12:57","unixtime":1719917851,"uptime":62328,"ram_size":261744,"ram_free":151436,"fs_size":1048576,"fs_free":712704,"cfg_rev":10,"kvs_rev":1,"schedule_rev":0,"webhook_rev":0,"available_updates":{"stable":{"version":"1.3.3"}},"reset_reason":1},"wifi":{"sta_ip":"192.168.178.89","status":"got ip","ssid":"FritzBox 8 2.4","rssi":-62},"ws":{"connected":false}}` + // EM1.GetStatus Endpoint + var res Gen2EM1Status + jsonstr := `{"id":"0","current":1.473,"voltage":226.9,"act_power":-332.2,"aprt_power":335,"pf":0.99,"freq":50,"calibration":"factory"}` require.NoError(t, json.Unmarshal([]byte(jsonstr), &res)) - - assert.Equal(t, 3551.682, res.Pm0.Aenergy.Total) - assert.Equal(t, 1780.1, res.Pm0.Apower) + assert.Equal(t, -332.2, res.ActPower) + assert.Equal(t, 1.473, res.Current) + assert.Equal(t, 226.9, res.Voltage) } { - // Shelly Pro 3EM - var res Gen2StatusResponse - - jsonstr := `{"ble":{},"bthome":{"errors":["bluetooth_disabled"]},"cloud":{"connected":true},"em1:0":{"id":0,"current":3.705,"voltage":242.8,"act_power":598.9,"aprt_power":900.6,"pf":0.66,"freq":50.0,"calibration":"factory"},"em1:1":{"id":1,"current":0.194,"voltage":242.8,"act_power":0.0,"aprt_power":47.2,"pf":0.00,"freq":50.0,"calibration":"factory"},"em1:2":{"id":2,"current":0.027,"voltage":242.8,"act_power":0.0,"aprt_power":6.6,"pf":0.00,"freq":50.0,"calibration":"factory"},"em1data:0":{"id":0,"total_act_energy":3458.24,"total_act_ret_energy":1605.24},"em1data:1":{"id":1,"total_act_energy":2768.67,"total_act_ret_energy":25.49},"em1data:2":{"id":2,"total_act_energy":3.09,"total_act_ret_energy":0.71},"eth":{"ip":null},"modbus":{},"mqtt":{"connected":false},"sys":{"mac":"FCE8C0DBA850","restart_required":false,"time":"19:46","unixtime":1731404780,"uptime":563,"ram_size":247148,"ram_free":110596,"fs_size":524288,"fs_free":176128,"cfg_rev":21,"kvs_rev":0,"schedule_rev":3,"webhook_rev":1,"available_updates":{},"reset_reason":3},"temperature:0":{"id": 0,"tC":39.0, "tF":102.2},"wifi":{"sta_ip":"192.168.40.174","status":"got ip","ssid":"IoT","rssi":-67},"ws":{"connected":false}}` + // EM1Data.GetStatus Endpoint + var res Gen2EM1Data + jsonstr := `{"id":"0","total_act_energy":1264.15,"total_act_ret_energy":144792.28}` require.NoError(t, json.Unmarshal([]byte(jsonstr), &res)) - // Channel 0 (1) - assert.Equal(t, 598.9, res.Em0.ActPower) - assert.Equal(t, 3.705, res.Em0.Current) - assert.Equal(t, 242.8, res.Em0.Voltage) - assert.Equal(t, 3458.24, res.Em0Data.TotalActEnergy) - assert.Equal(t, 1605.24, res.Em0Data.TotalActRetEnergy) - // Channel 1 (2) - assert.Equal(t, 0.0, res.Em1.ActPower) - assert.Equal(t, 0.194, res.Em1.Current) - assert.Equal(t, 242.8, res.Em1.Voltage) - assert.Equal(t, 2768.67, res.Em1Data.TotalActEnergy) - assert.Equal(t, 25.49, res.Em1Data.TotalActRetEnergy) - // Channel 2 (3) - assert.Equal(t, 0.0, res.Em2.ActPower) - assert.Equal(t, 0.027, res.Em2.Current) - assert.Equal(t, 242.8, res.Em2.Voltage) - assert.Equal(t, 3.09, res.Em2Data.TotalActEnergy) - assert.Equal(t, 0.71, res.Em2Data.TotalActRetEnergy) - } - - { - // Shelly Pro EM-50 channel 0 + 1 - var res Gen2StatusResponse - - jsonstr := `{"ble":{},"bthome":{"errors":["bluetooth_disabled"]},"cloud":{"connected":true},"em1:0":{"id":0,"current":1.473,"voltage":226.9,"act_power":-332.2,"aprt_power":335.0,"pf":0.99,"freq":50.0,"calibration":"factory"},"em1:1":{"id":1,"current":0.428,"voltage":227.0,"act_power":-38.5,"aprt_power":97.4,"pf":0.38,"freq":50.0,"calibration":"factory"},"em1data:0":{"id":0,"total_act_energy":1264.15,"total_act_ret_energy":144792.28},"em1data:1":{"id":1,"total_act_energy":48002.83,"total_act_ret_energy":33241.59},"eth":{"ip":null},"modbus":{},"mqtt":{"connected":false},"switch:0":{"id":0, "source":"HTTP_in", "output":false,"temperature":{"tC":46.4, "tF":115.5}},"sys":{"mac":"08F9E0E8AF2C","restart_required":false,"time":"10:42","unixtime":1742809323,"uptime":3671372,"ram_size":249680,"ram_free":107492,"fs_size":524288,"fs_free":188416,"cfg_rev":12,"kvs_rev":0,"schedule_rev":1,"webhook_rev":0,"available_updates":{"beta":{"version":"1.5.1-beta2"}},"reset_reason":3},"wifi":{"sta_ip":"192.168.1.120","status":"got ip","ssid":"Spaetzlewerk","rssi":-61},"ws":{"connected":false}}` - require.NoError(t, json.Unmarshal([]byte(jsonstr), &res)) - // Channel 0 (1) - assert.Equal(t, -332.2, res.Em0.ActPower) - assert.Equal(t, 1.473, res.Em0.Current) - assert.Equal(t, 226.9, res.Em0.Voltage) - assert.Equal(t, 1264.15, res.Em0Data.TotalActEnergy) - assert.Equal(t, 144792.28, res.Em0Data.TotalActRetEnergy) - // Channel 1 (2) - assert.Equal(t, -38.5, res.Em1.ActPower) - assert.Equal(t, 0.428, res.Em1.Current) - assert.Equal(t, 227.0, res.Em1.Voltage) - assert.Equal(t, 48002.83, res.Em1Data.TotalActEnergy) - assert.Equal(t, 33241.59, res.Em1Data.TotalActRetEnergy) + assert.Equal(t, 1264.15, res.TotalActEnergy) + assert.Equal(t, 144792.28, res.TotalActRetEnergy) } } diff --git a/meter/shelly/switch.go b/meter/shelly/switch.go deleted file mode 100644 index 466c7eec6..000000000 --- a/meter/shelly/switch.go +++ /dev/null @@ -1,147 +0,0 @@ -package shelly - -import ( - "errors" - "fmt" - "math" - "strings" -) - -type Switch struct { - conn *Connection -} - -func NewSwitch(conn *Connection) *Switch { - res := &Switch{ - conn: conn, - } - - return res -} - -// CurrentPower implements the api.Meter interface -func (sh *Switch) CurrentPower() (float64, error) { - var power float64 - - switch sh.conn.gen { - case 0, 1: - var res Gen1Status - uri := fmt.Sprintf("%s/status", sh.conn.uri) - if err := sh.conn.GetJSON(uri, &res); err != nil { - return 0, err - } - - switch { - case sh.conn.channel < len(res.Meters): - power = res.Meters[sh.conn.channel].Power - case sh.conn.channel < len(res.EMeters): - power = res.EMeters[sh.conn.channel].Power - default: - return 0, errors.New("invalid channel, missing power meter") - } - - default: - var res Gen2StatusResponse - if err := sh.conn.execGen2Cmd("Shelly.GetStatus", false, &res); err != nil { - return 0, err - } - - switch sh.conn.channel { - case 1: - power = res.Switch1.Apower + res.Pm1.Apower + res.Em1.ActPower - case 2: - power = res.Switch2.Apower + res.Pm2.Apower + res.Em2.ActPower - default: - power = res.Switch0.Apower + res.Pm0.Apower + res.Em0.ActPower - } - } - - // Assure positive power response (Gen 1 EM devices can provide negative values) - return math.Abs(power), nil -} - -// Enabled implements the api.Charger interface -func (sh *Switch) Enabled() (bool, error) { - switch sh.conn.gen { - case 0, 1: - var res Gen1SwitchResponse - uri := fmt.Sprintf("%s/relay/%d", sh.conn.uri, sh.conn.channel) - err := sh.conn.GetJSON(uri, &res) - return res.Ison, err - - default: - var res Gen2SwitchStatus - err := sh.conn.execGen2Cmd("Switch.GetStatus", false, &res) - return res.Output, err - } -} - -// Enable implements the api.Charger interface -func (sh *Switch) Enable(enable bool) error { - var err error - onoff := map[bool]string{true: "on", false: "off"} - - switch sh.conn.gen { - case 0, 1: - var res Gen1SwitchResponse - uri := fmt.Sprintf("%s/relay/%d?turn=%s", sh.conn.uri, sh.conn.channel, onoff[enable]) - err = sh.conn.GetJSON(uri, &res) - - default: - var res Gen2SwitchStatus - err = sh.conn.execGen2Cmd("Switch.Set", enable, &res) - } - - return err -} - -// TotalEnergy implements the api.Meter interface -func (sh *Switch) TotalEnergy() (float64, error) { - var energy float64 - - switch sh.conn.gen { - case 0, 1: - var res Gen1Status - uri := fmt.Sprintf("%s/status", sh.conn.uri) - if err := sh.conn.GetJSON(uri, &res); err != nil { - return 0, err - } - - switch { - case sh.conn.channel < len(res.Meters): - energy = res.Meters[sh.conn.channel].Total - case sh.conn.channel < len(res.EMeters): - energy = res.EMeters[sh.conn.channel].Total - default: - return 0, errors.New("invalid channel, missing power meter") - } - - energy = gen1Energy(sh.conn.model, energy) - - default: - var res Gen2StatusResponse - if err := sh.conn.execGen2Cmd("Shelly.GetStatus", false, &res); err != nil { - return 0, err - } - - switch sh.conn.channel { - case 1: - energy = res.Switch1.Aenergy.Total + res.Pm1.Aenergy.Total + res.Em1Data.TotalActEnergy - res.Em1Data.TotalActRetEnergy - case 2: - energy = res.Switch2.Aenergy.Total + res.Pm2.Aenergy.Total + res.Em2Data.TotalActEnergy - res.Em2Data.TotalActRetEnergy - default: - energy = res.Switch0.Aenergy.Total + res.Pm0.Aenergy.Total + res.Em0Data.TotalActEnergy - res.Em0Data.TotalActRetEnergy - } - } - - return energy / 1000, nil -} - -// gen1Energy in kWh -func gen1Energy(devicetype string, energy float64) float64 { - // Gen 1 Shelly EM devices are providing Watt hours, Gen 1 Shelly PM devices are providing Watt minutes - if !strings.Contains(devicetype, "EM") { - energy /= 60 - } - return energy -} diff --git a/meter/shelly_decorators.go b/meter/shelly_decorators.go new file mode 100644 index 000000000..39357a597 --- /dev/null +++ b/meter/shelly_decorators.go @@ -0,0 +1,111 @@ +package meter + +// Code generated by github.com/evcc-io/evcc/cmd/tools/decorate.go. DO NOT EDIT. + +import ( + "github.com/evcc-io/evcc/api" +) + +func decorateShelly(base *Shelly, phaseVoltages func() (float64, float64, float64, error), phaseCurrents func() (float64, float64, float64, error), phasePowers func() (float64, float64, float64, error)) api.Meter { + switch { + case phaseCurrents == nil && phaseVoltages == nil: + return base + + case phaseCurrents == nil && phaseVoltages != nil: + return &struct { + *Shelly + api.PhaseVoltages + }{ + Shelly: base, + PhaseVoltages: &decorateShellyPhaseVoltagesImpl{ + phaseVoltages: phaseVoltages, + }, + } + + case phaseCurrents != nil && phasePowers == nil && phaseVoltages == nil: + return &struct { + *Shelly + api.PhaseCurrents + }{ + Shelly: base, + PhaseCurrents: &decorateShellyPhaseCurrentsImpl{ + phaseCurrents: phaseCurrents, + }, + } + + case phaseCurrents != nil && phasePowers == nil && phaseVoltages != nil: + return &struct { + *Shelly + api.PhaseCurrents + api.PhaseVoltages + }{ + Shelly: base, + PhaseCurrents: &decorateShellyPhaseCurrentsImpl{ + phaseCurrents: phaseCurrents, + }, + PhaseVoltages: &decorateShellyPhaseVoltagesImpl{ + phaseVoltages: phaseVoltages, + }, + } + + case phaseCurrents != nil && phasePowers != nil && phaseVoltages == nil: + return &struct { + *Shelly + api.PhaseCurrents + api.PhasePowers + }{ + Shelly: base, + PhaseCurrents: &decorateShellyPhaseCurrentsImpl{ + phaseCurrents: phaseCurrents, + }, + PhasePowers: &decorateShellyPhasePowersImpl{ + phasePowers: phasePowers, + }, + } + + case phaseCurrents != nil && phasePowers != nil && phaseVoltages != nil: + return &struct { + *Shelly + api.PhaseCurrents + api.PhasePowers + api.PhaseVoltages + }{ + Shelly: base, + PhaseCurrents: &decorateShellyPhaseCurrentsImpl{ + phaseCurrents: phaseCurrents, + }, + PhasePowers: &decorateShellyPhasePowersImpl{ + phasePowers: phasePowers, + }, + PhaseVoltages: &decorateShellyPhaseVoltagesImpl{ + phaseVoltages: phaseVoltages, + }, + } + } + + return nil +} + +type decorateShellyPhaseCurrentsImpl struct { + phaseCurrents func() (float64, float64, float64, error) +} + +func (impl *decorateShellyPhaseCurrentsImpl) Currents() (float64, float64, float64, error) { + return impl.phaseCurrents() +} + +type decorateShellyPhasePowersImpl struct { + phasePowers func() (float64, float64, float64, error) +} + +func (impl *decorateShellyPhasePowersImpl) Powers() (float64, float64, float64, error) { + return impl.phasePowers() +} + +type decorateShellyPhaseVoltagesImpl struct { + phaseVoltages func() (float64, float64, float64, error) +} + +func (impl *decorateShellyPhaseVoltagesImpl) Voltages() (float64, float64, float64, error) { + return impl.phaseVoltages() +} diff --git a/meter/shelly_energymeter.go b/meter/shelly_energymeter.go deleted file mode 100644 index 090a12bbd..000000000 --- a/meter/shelly_energymeter.go +++ /dev/null @@ -1,33 +0,0 @@ -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-energymeter", NewShellyEnergyMeterFromConfig) -} - -// NewShellyFromConfig creates a Shelly charger from generic config -func NewShellyEnergyMeterFromConfig(other map[string]interface{}) (api.Meter, error) { - var cc struct { - URI string - User string - Password string - Channel int - } - - if err := util.DecodeOther(other, &cc); err != nil { - return nil, err - } - - conn, err := shelly.NewConnection(cc.URI, cc.User, cc.Password, cc.Channel) - if err != nil { - return nil, err - } - - return shelly.NewEnergyMeter(conn), nil -} diff --git a/templates/definition/meter/shelly-1pm.yaml b/templates/definition/meter/shelly-1pm.yaml index bb6383494..404b03a49 100644 --- a/templates/definition/meter/shelly-1pm.yaml +++ b/templates/definition/meter/shelly-1pm.yaml @@ -21,4 +21,5 @@ render: | {{- if .password }} password: {{ .password }} {{- end }} + usage: {{ .usage }} channel: {{ .channel }} # shelly device relay channel diff --git a/templates/definition/meter/shelly-pro-3em.yaml b/templates/definition/meter/shelly-pro-3em.yaml index 30d3a55af..8ae0d48ef 100644 --- a/templates/definition/meter/shelly-pro-3em.yaml +++ b/templates/definition/meter/shelly-pro-3em.yaml @@ -10,7 +10,7 @@ params: - name: user - name: password render: | - type: shelly-energymeter + type: shelly uri: http://{{ .host }} # shelly device ip address (local) {{- if .user }} user: {{ .user }} @@ -18,4 +18,5 @@ render: | {{- if .password }} password: {{ .password }} {{- end }} + usage: {{ .usage }} channel: 0 # shelly device relay channel diff --git a/tests/simulator/api.js b/tests/simulator/api.js index c8e0ef8e0..c55e8fed7 100644 --- a/tests/simulator/api.js +++ b/tests/simulator/api.js @@ -72,15 +72,12 @@ const teslaloggerMiddleware = (req, res, next) => { }; const shellyMiddleware = (req, res, next) => { - // simulate a shelly gen1 device api. implement power and energy + // simulate a shelly gen2 device api. implement power and energy if (req.method === "GET" && req.originalUrl === "/shelly") { - res.end(JSON.stringify({ gen: 2 })); - } else if (req.originalUrl === "/rpc/Shelly.GetStatus") { + res.end(JSON.stringify({ model: "SNSW-001P16EU", gen: 2 })); + } else if (req.originalUrl === "/rpc/Switch.GetStatus?id=0") { res.end( - JSON.stringify({ - "switch:0": { apower: state.site.pv.power, aenergy: { total: state.site.pv.energy } }, - }) - ); + JSON.stringify({ apower: state.site.pv.power, aenergy: { total: state.site.pv.energy } })); } else { next(); }