Shelly: add usage and caching (BC) (#20570)
This commit is contained in:
parent
e08016f6e9
commit
21b76d5627
15 changed files with 714 additions and 407 deletions
|
|
@ -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)
|
||||
|
|
|
|||
111
charger/shelly_decorators.go
Normal file
111
charger/shelly_decorators.go
Normal file
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
111
meter/shelly_decorators.go
Normal file
111
meter/shelly_decorators.go
Normal file
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -21,4 +21,5 @@ render: |
|
|||
{{- if .password }}
|
||||
password: {{ .password }}
|
||||
{{- end }}
|
||||
usage: {{ .usage }}
|
||||
channel: {{ .channel }} # shelly device relay channel
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue