Sonoff: add switch socket charger (#33058)
This commit is contained in:
parent
8661c66fc7
commit
8edf92a103
4 changed files with 318 additions and 0 deletions
156
charger/sonoff.go
Normal file
156
charger/sonoff.go
Normal file
|
|
@ -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
|
||||
}
|
||||
57
charger/sonoff/types.go
Normal file
57
charger/sonoff/types.go
Normal file
|
|
@ -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"`
|
||||
}
|
||||
78
charger/sonoff_test.go
Normal file
78
charger/sonoff_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
27
templates/definition/charger/sonoff.yaml
Normal file
27
templates/definition/charger/sonoff.yaml
Normal file
|
|
@ -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" . }}
|
||||
Loading…
Add table
Add a link
Reference in a new issue