diff --git a/meter/shelly/gen2.go b/meter/shelly/gen2.go index 787b38600..79f197cb7 100644 --- a/meter/shelly/gen2.go +++ b/meter/shelly/gen2.go @@ -4,7 +4,7 @@ import ( "fmt" "net/http" "slices" - "strconv" + "strings" "time" "github.com/evcc-io/evcc/util" @@ -14,13 +14,17 @@ import ( // Gen2API endpoint reference: https://shelly-api-docs.shelly.cloud/gen2/ -type Gen2RpcPost struct { +type Gen2RpcRequest struct { Id int `json:"id"` - On bool `json:"on"` Src string `json:"src"` Method string `json:"method"` } +type Gen2SetRpcPost struct { + Gen2RpcRequest + On bool `json:"on"` +} + type Gen2Methods struct { Methods []string } @@ -67,25 +71,31 @@ type Gen2EM1Data struct { TotalActRetEnergy float64 `json:"total_act_ret_energy"` } +type Gen2ProAddOnGetPeripherals struct { + DigitalOut map[string]any `json:"digital_out"` +} + var _ Generation = (*gen2)(nil) +const apisrc string = "evcc" + type gen2 struct { *request.Helper - uri string - channel int - model string - methods []string - switchstatus util.Cacheable[Gen2SwitchStatus] - em1status func() (Gen2EM1Status, error) - em1data func() (Gen2EM1Data, error) - emstatus func() (Gen2EMStatus, error) - emdata func() (Gen2EMData, error) + uri string + switchchannel int + model string + methods []string + switchstatus util.Cacheable[Gen2SwitchStatus] + em1status func() (Gen2EM1Status, error) + em1data func() (Gen2EM1Data, error) + emstatus func() (Gen2EMStatus, error) + emdata func() (Gen2EMData, error) } -func apiCall[T any](c *gen2, api string) func() (T, error) { +func apiCall[T any](c *gen2, id int, method 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 { + if err := c.execCmd(id, method, &res); err != nil { return res, err } return res, nil @@ -97,10 +107,10 @@ func newGen2(helper *request.Helper, uri, model string, channel int, user, passw // Shelly GEN 2+ API // https://shelly-api-docs.shelly.cloud/gen2/ c := &gen2{ - Helper: helper, - uri: fmt.Sprintf("%s/rpc", util.DefaultScheme(uri, "http")), - channel: channel, - model: model, + Helper: helper, + uri: fmt.Sprintf("%s/rpc", util.DefaultScheme(uri, "http")), + switchchannel: channel, + model: model, } // Shelly gen 2 rfc7616 authentication @@ -110,31 +120,39 @@ func newGen2(helper *request.Helper, uri, model string, channel int, user, passw } var res Gen2Methods - if err := c.execCmd("Shelly.ListMethods", false, &res); err != nil { + if err := c.execCmd(channel, "Shelly.ListMethods", &res); err != nil { return nil, err } c.methods = res.Methods - if c.hasMethod("PM1.GetStatus") { - c.switchstatus = util.ResettableCached(apiCall[Gen2SwitchStatus](c, "PM1.GetStatus"), cache) - } else { - c.switchstatus = util.ResettableCached(apiCall[Gen2SwitchStatus](c, "Switch.GetStatus"), cache) + // Optional change of switchchannel for Pro shellies with peripherals + if c.hasMethod("ProOutputAddon.GetPeripherals") { + var err error + c.switchchannel, err = c.getAddOnSwitchId(channel) + if err != nil { + return nil, err + } } - 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) + + if c.hasMethod("PM1.GetStatus") { + c.switchstatus = util.ResettableCached(apiCall[Gen2SwitchStatus](c, channel, "PM1.GetStatus"), cache) + } else { + c.switchstatus = util.ResettableCached(apiCall[Gen2SwitchStatus](c, c.switchchannel, "Switch.GetStatus"), cache) + } + c.em1status = util.Cached(apiCall[Gen2EM1Status](c, channel, "EM1.GetStatus"), cache) + c.em1data = util.Cached(apiCall[Gen2EM1Data](c, channel, "EM1Data.GetStatus"), cache) + c.emstatus = util.Cached(apiCall[Gen2EMStatus](c, channel, "EM.GetStatus"), cache) + c.emdata = util.Cached(apiCall[Gen2EMData](c, channel, "EMData.GetStatus"), cache) return c, nil } // execCmd executes a shelly api gen2+ command and provides the response -func (c *gen2) execCmd(method string, enable bool, res any) error { - data := &Gen2RpcPost{ - Id: c.channel, - On: enable, - Src: "evcc", +func (c *gen2) execCmd(id int, method string, res any) error { + data := &Gen2RpcRequest{ + Id: id, + Src: apisrc, Method: method, } @@ -146,6 +164,25 @@ func (c *gen2) execCmd(method string, enable bool, res any) error { return c.DoJSON(req, &res) } +// execCmd executes a shelly api gen2+ command and provides the response +func (c *gen2) execEnableCmd(id int, method string, enable bool, res any) error { + data := &Gen2SetRpcPost{ + Gen2RpcRequest: Gen2RpcRequest{ + Id: id, + Src: apisrc, + Method: method, + }, + On: enable, + } + + 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 { @@ -180,7 +217,7 @@ func (c *gen2) Enabled() (bool, error) { 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) + return c.execEnableCmd(c.switchchannel, "Switch.Set", enable, &res) } // TotalEnergy implements the api.Meter interface @@ -282,3 +319,27 @@ func (c *gen2) hasEMEndpoint() bool { func (c *gen2) hasMethod(method string) bool { return slices.Contains(c.methods, method) } + +func (c *gen2) getAddOnSwitchId(channel int) (int, error) { + var res Gen2ProAddOnGetPeripherals + if err := c.execCmd(channel, "ProOutputAddon.GetPeripherals", &res); err != nil { + return channel, err + } + + return parseAddOnSwitchID(channel, res) +} + +func parseAddOnSwitchID(channel int, res Gen2ProAddOnGetPeripherals) (int, error) { + for key := range res.DigitalOut { + if strings.HasPrefix(key, "switch:") { + var id int + if _, err := fmt.Sscanf(key, "switch:%d", &id); err != nil { + return 0, fmt.Errorf("failed to get add-on switch id: %w", err) + } + return id, nil + } + } + + // if no switch ID is found, return the channel as default + return channel, nil +} diff --git a/meter/shelly/gen2_test.go b/meter/shelly/gen2_test.go index b12ac0c8c..91b2661c1 100644 --- a/meter/shelly/gen2_test.go +++ b/meter/shelly/gen2_test.go @@ -37,4 +37,46 @@ func TestUnmarshalGen2StatusResponse(t *testing.T) { assert.Equal(t, 1264.15, res.TotalActEnergy) assert.Equal(t, 144792.28, res.TotalActRetEnergy) } + + { + // ProOutputAddon.GetPeripherals Endpoint + var res Gen2ProAddOnGetPeripherals + channel := 0 + + // Test with a valid switch ID + jsonstr := `{"digital_out":{"switch:100":{}}}` + require.NoError(t, json.Unmarshal([]byte(jsonstr), &res)) + assert.NotEmpty(t, res.DigitalOut) + id, err := parseAddOnSwitchID(channel, res) + require.NoError(t, err) + assert.Equal(t, 100, id) + // Test with no AddOn installed + res = Gen2ProAddOnGetPeripherals{} + jsonstr = `{"code":404,"message":"No handler for ProOutputAddon.GetPeripherals"}` + require.NoError(t, json.Unmarshal([]byte(jsonstr), &res)) + id, err = parseAddOnSwitchID(channel, res) + require.NoError(t, err) + assert.Equal(t, 0, id) + // Test for empty digital_out map in AddOn response + res = Gen2ProAddOnGetPeripherals{} + jsonstr = `{"digital_out":{}}` + require.NoError(t, json.Unmarshal([]byte(jsonstr), &res)) + id, err = parseAddOnSwitchID(channel, res) + require.NoError(t, err) + assert.Equal(t, 0, id) + // Test with multiple AddOns installed (only the first ID will be returned) + res = Gen2ProAddOnGetPeripherals{} + jsonstr = `{"digital_out":{"switch:100":{},"switch:101":{}}}` + require.NoError(t, json.Unmarshal([]byte(jsonstr), &res)) + id, err = parseAddOnSwitchID(channel, res) + require.NoError(t, err) + assert.Equal(t, 100, id) + // Test for malformed switch keys + res = Gen2ProAddOnGetPeripherals{} + jsonstr = `{"digital_out":{"switch:abc":{}}}` + require.NoError(t, json.Unmarshal([]byte(jsonstr), &res)) + id, err = parseAddOnSwitchID(channel, res) + require.ErrorContains(t, err, "failed to get add-on switch id:") + assert.Equal(t, 0, id) + } } diff --git a/tests/simulator/api.ts b/tests/simulator/api.ts index d2b2a95e3..57fff59b3 100644 --- a/tests/simulator/api.ts +++ b/tests/simulator/api.ts @@ -100,7 +100,7 @@ const shellyMiddleware = ( res.end(JSON.stringify({ gen: 2 })); } else if (req.originalUrl === "/rpc/Shelly.ListMethods") { res.end(JSON.stringify({ methods: ["Switch.GetStatus"] })); - } else if (req.originalUrl === "/rpc/Switch.GetStatus?id=0") { + } else if (req.originalUrl === "/rpc/Switch.GetStatus") { res.end( JSON.stringify({ apower: state.site.pv.power,