From 8edf92a103e6ff0d90ee3b84ae577a4f83880a88 Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 22 Aug 2026 13:14:09 +0200 Subject: [PATCH] Sonoff: add switch socket charger (#33058) --- charger/sonoff.go | 156 +++++++++++++++++++++++ charger/sonoff/types.go | 57 +++++++++ charger/sonoff_test.go | 78 ++++++++++++ templates/definition/charger/sonoff.yaml | 27 ++++ 4 files changed, 318 insertions(+) create mode 100644 charger/sonoff.go create mode 100644 charger/sonoff/types.go create mode 100644 charger/sonoff_test.go create mode 100644 templates/definition/charger/sonoff.yaml diff --git a/charger/sonoff.go b/charger/sonoff.go new file mode 100644 index 000000000..11711a0e2 --- /dev/null +++ b/charger/sonoff.go @@ -0,0 +1,156 @@ +package charger + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/api/implement" + "github.com/evcc-io/evcc/charger/sonoff" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" + "github.com/evcc-io/evcc/util/transport" +) + +// Sonoff charger implementation +type Sonoff struct { + implement.Caps + *switchSocket + *request.Helper + uri string + channel int + statusG util.Cacheable[sonoff.SwitchStatus] + meterG util.Cacheable[sonoff.MeterStatus] +} + +func init() { + registry.Add("sonoff", NewSonoffFromConfig) +} + +// NewSonoffFromConfig creates a Sonoff charger from generic config +func NewSonoffFromConfig(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 + }{ + User: "admin", + Channel: 1, + Cache: time.Second, + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + return NewSonoff(cc.embed, cc.URI, cc.User, cc.Password, cc.Channel, cc.StandbyPower, cc.Cache) +} + +// NewSonoff creates a Sonoff charger +func NewSonoff(embed embed, uri, user, password string, channel int, standbypower float64, cache time.Duration) (api.Charger, error) { + if uri == "" { + return nil, errors.New("missing uri") + } + + log := util.NewLogger("sonoff").Redact(user, password) + + c := &Sonoff{ + Caps: implement.New(), + Helper: request.NewHelper(log), + uri: fmt.Sprintf("%s/rpc", util.DefaultScheme(strings.TrimRight(uri, "/"), "http")), + channel: channel, + } + + // rfc7616 digest authentication + // https://help.sonoff.tech/docs/API_Authentication + if password != "" { + c.Client.Transport = transport.Digest(user, password, c.Client.Transport) + } + + c.statusG = util.ResettableCached(func() (sonoff.SwitchStatus, error) { + var res sonoff.SwitchStatus + err := c.call("Switch.GetStatus", sonoff.ChannelParams{Id: channel}, &res) + return res, err + }, cache) + + c.meterG = util.ResettableCached(func() (sonoff.MeterStatus, error) { + var res sonoff.MeterStatus + err := c.call("Meter.GetStatus", sonoff.ChannelParams{Id: channel}, &res) + return res, err + }, cache) + + // validate switch channel + if _, err := c.statusG.Get(); err != nil { + return nil, err + } + + c.switchSocket = NewSwitchSocket(&embed, c.Enabled, c.currentPower, standbypower) + + // metering is optional, devices without meter component fail the call + if _, err := c.meterG.Get(); err == nil { + implement.Has(c, implement.MeterEnergy(c.totalEnergy)) + } + + return c, nil +} + +// call executes a single rpc method and decodes its result into res +func (c *Sonoff) call(method string, params, res any) error { + data := sonoff.Request{ + Id: c.channel, + Src: "evcc", + Method: method, + Params: params, + } + + req, err := request.New(http.MethodPost, c.uri, request.MarshalJSON(data), request.JSONEncoding) + if err != nil { + return err + } + + var resp sonoff.Response + if err := c.DoJSON(req, &resp); err != nil { + return err + } + + if resp.Error != nil { + return fmt.Errorf("%s: %w", method, resp.Error) + } + + if res == nil { + return nil + } + + return json.Unmarshal(resp.Result, res) +} + +// Enabled implements the api.Charger interface +func (c *Sonoff) Enabled() (bool, error) { + res, err := c.statusG.Get() + return res.On, err +} + +// Enable implements the api.Charger interface +func (c *Sonoff) Enable(enable bool) error { + c.statusG.Reset() + + return c.call("Switch.Set", sonoff.SetParams{Id: c.channel, On: enable}, nil) +} + +func (c *Sonoff) currentPower() (float64, error) { + res, err := c.meterG.Get() + return res.Power / 100, err +} + +func (c *Sonoff) totalEnergy() (float64, error) { + res, err := c.meterG.Get() + return res.TotalEnergy / 100, err +} diff --git a/charger/sonoff/types.go b/charger/sonoff/types.go new file mode 100644 index 000000000..558b9ab62 --- /dev/null +++ b/charger/sonoff/types.go @@ -0,0 +1,57 @@ +package sonoff + +import ( + "encoding/json" + "fmt" +) + +// Sonoff device rpc api +// https://help.sonoff.tech/docs/API_Welcome + +// Request is a json-rpc request frame +type Request struct { + Id int `json:"id"` + Src string `json:"src"` + Method string `json:"method"` + Params any `json:"params,omitempty"` +} + +// Response is a json-rpc response frame. Error frames are returned with http status 200. +type Response struct { + Result json.RawMessage `json:"result"` + Error *Error `json:"error"` +} + +// Error is a json-rpc error frame +type Error struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func (e *Error) Error() string { + return fmt.Sprintf("%s (%d)", e.Message, e.Code) +} + +// ChannelParams addresses a single device channel +type ChannelParams struct { + Id int `json:"id"` +} + +// SetParams are the Switch.Set parameters +type SetParams struct { + Id int `json:"id"` + On bool `json:"on"` +} + +// SwitchStatus is the Switch.GetStatus result +// https://help.sonoff.tech/docs/API_Switch +type SwitchStatus struct { + On bool `json:"on"` +} + +// MeterStatus is the Meter.GetStatus result, scaled by 100 (energy in 0.01kWh) +// https://help.sonoff.tech/docs/API_Meter +type MeterStatus struct { + Power float64 `json:"power"` + TotalEnergy float64 `json:"total_energy"` +} diff --git a/charger/sonoff_test.go b/charger/sonoff_test.go new file mode 100644 index 000000000..d78d22a62 --- /dev/null +++ b/charger/sonoff_test.go @@ -0,0 +1,78 @@ +package charger + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/evcc-io/evcc/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSonoff(t *testing.T) { + var on bool + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, "/rpc", r.URL.Path) + + var req struct { + Method string `json:"method"` + Params struct { + Id int `json:"id"` + On bool `json:"on"` + } `json:"params"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + require.Equal(t, 1, req.Params.Id) + + var res any + switch req.Method { + case "Switch.GetStatus": + res = map[string]any{"result": map[string]any{"id": 1, "on": on}} + case "Switch.Set": + on = req.Params.On + res = map[string]any{"result": map[string]any{}} + case "Meter.GetStatus": + // 1100W, 123.45kWh + res = map[string]any{"result": map[string]any{ + "power": 110000, "total_energy": 12345, + }} + default: + res = map[string]any{"error": map[string]any{"code": -12805, "message": "internal error"}} + } + + require.NoError(t, json.NewEncoder(w).Encode(res)) + })) + defer srv.Close() + + c, err := NewSonoff(embed{}, srv.URL, "", "", 1, 0, 0) + require.NoError(t, err) + + enabled, err := c.Enabled() + require.NoError(t, err) + assert.False(t, enabled) + + require.NoError(t, c.Enable(true)) + + enabled, err = c.Enabled() + require.NoError(t, err) + assert.True(t, enabled) + + power, err := c.(api.Meter).CurrentPower() + require.NoError(t, err) + assert.Equal(t, 1100.0, power) + + me, ok := api.Cap[api.MeterEnergy](c) + require.True(t, ok) + + energy, err := me.TotalEnergy() + require.NoError(t, err) + assert.Equal(t, 123.45, energy) + + status, err := c.Status() + require.NoError(t, err) + assert.Equal(t, api.StatusC, status) +} diff --git a/templates/definition/charger/sonoff.yaml b/templates/definition/charger/sonoff.yaml new file mode 100644 index 000000000..0de5eda26 --- /dev/null +++ b/templates/definition/charger/sonoff.yaml @@ -0,0 +1,27 @@ +template: sonoff +products: + - { brand: SONOFF, description: { generic: MINI-1GSP } } +requirements: + description: + de: Benötigt ein Gerät mit SONOFF RPC API. Das Passwort wird in der eWeLink App vergeben. + en: Requires a device with SONOFF RPC API. The password is set in the eWeLink app. +capabilities: ["meter"] +group: switchsockets +params: + - name: host + - name: user + default: admin + - name: password + - name: channel + default: 1 + description: + de: Schaltkanal + en: Relay channel + - preset: switchsocket +render: | + type: sonoff + uri: http://{{ .host }} + user: {{ .user }} + password: {{ .password }} + channel: {{ .channel }} + {{ include "switchsocket" . }}