From ae764c19ecd648e15bf525fe55a48c5b3a40876f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjamin=20B=C3=B6hmke?= Date: Sun, 4 Jul 2021 12:47:30 +0200 Subject: [PATCH] Add SMA/Speedwire plugin (#1173) --- README.md | 26 +++++- meter/sma.go | 191 +++++---------------------------------- provider/sma.go | 96 ++++++++++++++++++++ provider/sma/device.go | 62 +++++++++++++ provider/sma/discover.go | 163 +++++++++++++++++++++++++++++++++ 5 files changed, 370 insertions(+), 168 deletions(-) create mode 100644 provider/sma.go create mode 100644 provider/sma/device.go create mode 100644 provider/sma/discover.go diff --git a/README.md b/README.md index bfb08cd8d..f8f27e59d 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ EVCC is an extensible EV Charge Controller with PV integration implemented in [G - [MQTT (read/write)](#mqtt-readwrite) - [HTTP (read/write)](#http-readwrite) - [Websocket (read only)](#websocket-read-only) + - [SMA/Speedwire (read only)](#smaspeedwire-read-only) - [Javascript (read/write)](#javascript-readwrite) - [Shell Script (read/write)](#shell-script-readwrite) - [Calc (read only)](#calc-read-only) @@ -243,7 +244,7 @@ Available meter implementations are: - `modbus`: ModBus meters as supported by [MBMD](https://github.com/volkszaehler/mbmd#supported-devices). Configuration is similar to the [ModBus plugin](#modbus-readwrite) where `power` and `energy` specify the MBMD measurement value to use. Additionally, `soc` can specify an MBMD measurement value for home battery soc. Typical values are `power: Power`, `energy: Sum` and `soc: ChargeState` where only `power` applied per default. - `openwb`: OpenWB meters. Use `usage` to choose meter type: `grid`/`pv`/`battery`. -- `sma`: SMA Home Manager 2.0 and SMA Energy Meter. Power reading is configured out of the box but can be customized if necessary. To obtain specific energy readings define the desired Obis code (Import Energy: "1:1.8.0", Export Energy: "1:2.8.0"). +- `sma`: SMA Home Manager 2.0, SMA Energy Meter and Inverters via SMA Speedwire. - `tesla`: Tesla PowerWall meter. Use `usage` to choose meter type: `grid`/`pv`/`battery`. - `custom`: default meter implementation where meter readings- `power`, `energy`, per-phase `currents` and battery `soc` are configured using [plugins](#plugins) @@ -448,6 +449,29 @@ scale: 0.001 # floating point factor applied to result, e.g. for Wh to kWh conve timeout: 30s # error if no update received in 30 seconds ``` +### SMA/Speedwire (read only) + +The `sma` plugin provides an interface to SMA devices via the Speedwire protocol. + +Sample configuration (read only): + +```yaml +source: sma +uri: 192.168.4.51 # alternative to serial +serial: 123456 # alternative to uri +value: ActivePowerPlus # ID of value to read +password: "0000" # optional (default: 0000) +interface: eth0 # optional +scale: 1 # optional scale factor for value +``` + +Supported values for `value` can be found in the diagnostic dump of the command +`evcc meter` (with a configured SMA meter). + +All possible values can be found as const [here](https://gitlab.com/bboehmke/sunny/-/blob/master/values.go#L24) +(use the names of the const for `value`). + + ### Javascript (read/write) EVCC includes a bundled Javascript interpreter with Underscore.js library installed. The `js` plugin is able to execute Javascript code from the `script` tag. Useful for quick prototyping: diff --git a/meter/sma.go b/meter/sma.go index aa6885512..859f266cd 100644 --- a/meter/sma.go +++ b/meter/sma.go @@ -1,88 +1,25 @@ package meter import ( - "context" "errors" "fmt" "os" "sort" - "sync" - "sync/atomic" "text/tabwriter" - "time" "github.com/andig/evcc/api" + "github.com/andig/evcc/provider/sma" "github.com/andig/evcc/util" - "github.com/imdario/mergo" "gitlab.com/bboehmke/sunny" ) -const udpTimeout = 10 * time.Second - -// smaDiscoverer discovers SMA devices in background while providing already found devices -type smaDiscoverer struct { - conn *sunny.Connection - devices map[uint32]*sunny.Device - mux sync.RWMutex - done uint32 -} - -// run discover and store found devices -func (d *smaDiscoverer) run() { - devices := make(chan *sunny.Device) - - go func() { - for device := range devices { - d.mux.Lock() - d.devices[device.SerialNumber()] = device - d.mux.Unlock() - } - }() - - // discover devices and wait for results - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - d.conn.DiscoverDevices(ctx, devices, "") - cancel() - close(devices) - - // mark discover as done - atomic.AddUint32(&d.done, 1) -} - -func (d *smaDiscoverer) get(serial uint32) *sunny.Device { - d.mux.RLock() - defer d.mux.RUnlock() - return d.devices[serial] -} - -// deviceBySerial with the given serial number -func (d *smaDiscoverer) deviceBySerial(serial uint32) *sunny.Device { - start := time.Now() - for time.Since(start) < time.Second*3 { - // discover done -> return immediately regardless of result - if atomic.LoadUint32(&d.done) != 0 { - return d.get(serial) - } - - // device with serial found -> return - if device := d.get(serial); device != nil { - return device - } - - time.Sleep(time.Millisecond * 10) - } - return d.get(serial) -} - -// SMA supporting SMA Home Manager 2.0 and SMA Energy Meter 30 +// SMA supporting SMA Home Manager 2.0, SMA Energy Meter 30 and SMA inverter type SMA struct { log *util.Logger - mux *util.Waiter uri string iface string - values map[sunny.ValueID]interface{} scale float64 - device *sunny.Device + device *sma.Device } func init() { @@ -114,58 +51,32 @@ func NewSMAFromConfig(other map[string]interface{}) (api.Meter, error) { return NewSMA(cc.URI, cc.Password, cc.Interface, cc.Serial, cc.Scale) } -// map of created discover instances -var discoverers = make(map[string]*smaDiscoverer) - -// initialize sunny logger only once -var once sync.Once - // NewSMA creates a SMA Meter func NewSMA(uri, password, iface string, serial uint32, scale float64) (api.Meter, error) { - log := util.NewLogger("sma") - once.Do(func() { - sunny.Log = log.TRACE - }) - sm := &SMA{ - mux: util.NewWaiter(udpTimeout, func() { log.TRACE.Println("wait for initial value") }), - log: log, - uri: uri, - iface: iface, - values: make(map[sunny.ValueID]interface{}), - scale: scale, + log: util.NewLogger("sma"), + uri: uri, + iface: iface, + scale: scale, } - conn, err := sunny.NewConnection(iface) + discoverer, err := sma.GetDiscoverer(iface) if err != nil { - return nil, fmt.Errorf("connection failed: %w", err) + return nil, fmt.Errorf("failed to get discoverer failed: %w", err) } switch { case uri != "": - sm.device, err = conn.NewDevice(uri, password) + sm.device, err = discoverer.DeviceByIP(uri, password) if err != nil { return nil, err } case serial > 0: - discoverer, ok := discoverers[iface] - if !ok { - discoverer = &smaDiscoverer{ - conn: conn, - devices: make(map[uint32]*sunny.Device), - } - - go discoverer.run() - - discoverers[iface] = discoverer - } - - sm.device = discoverer.deviceBySerial(serial) + sm.device = discoverer.DeviceBySerial(serial, password) if sm.device == nil { return nil, fmt.Errorf("device not found: %d", serial) } - sm.device.SetPassword(password) default: return nil, errors.New("missing uri or serial") @@ -174,7 +85,7 @@ func NewSMA(uri, password, iface string, serial uint32, scale float64) (api.Mete // decorate api.Battery in case of inverter var soc func() (float64, error) if !sm.device.IsEnergyMeter() { - vals, err := sm.device.GetValues() + vals, err := sm.device.Values() if err != nil { return nil, err } @@ -184,75 +95,41 @@ func NewSMA(uri, password, iface string, serial uint32, scale float64) (api.Mete } } - go func() { - for range time.NewTicker(time.Second).C { - sm.updateValues() - } - }() - return decorateSMA(sm, soc), nil } -func (sm *SMA) updateValues() { - sm.mux.Lock() - defer sm.mux.Unlock() - - values, err := sm.device.GetValues() - if err == nil { - err = mergo.Merge(&sm.values, values, mergo.WithOverride) - } - - if err == nil { - sm.mux.Update() - } else { - sm.log.ERROR.Println(err) - } -} - -func (sm *SMA) hasValue() (map[sunny.ValueID]interface{}, error) { - elapsed := sm.mux.LockWithTimeout() - defer sm.mux.Unlock() - - if elapsed > 0 { - return nil, fmt.Errorf("update timeout: %v", elapsed.Truncate(time.Second)) - } - - return sm.values, nil -} - // CurrentPower implements the api.Meter interface func (sm *SMA) CurrentPower() (float64, error) { - values, err := sm.hasValue() - return sm.scale * (sm.asFloat(values[sunny.ActivePowerPlus]) - sm.asFloat(values[sunny.ActivePowerMinus])), err + values, err := sm.device.Values() + return sm.scale * (sma.AsFloat(values[sunny.ActivePowerPlus]) - sma.AsFloat(values[sunny.ActivePowerMinus])), err } var _ api.MeterEnergy = (*SMA)(nil) // TotalEnergy implements the api.MeterEnergy interface func (sm *SMA) TotalEnergy() (float64, error) { - values, err := sm.hasValue() - return sm.asFloat(values[sunny.ActiveEnergyPlus]) / 3600000, err + values, err := sm.device.Values() + return sma.AsFloat(values[sunny.ActiveEnergyPlus]) / 3600000, err } var _ api.MeterCurrent = (*SMA)(nil) // Currents implements the api.MeterCurrent interface func (sm *SMA) Currents() (float64, float64, float64, error) { - values, err := sm.hasValue() + values, err := sm.device.Values() - measurements := []sunny.ValueID{sunny.CurrentL1, sunny.CurrentL2, sunny.CurrentL3} - var vals [3]float64 - for i := 0; i < 3; i++ { - vals[i] = sm.asFloat(values[measurements[i]]) + var currents [3]float64 + for i, id := range []sunny.ValueID{sunny.CurrentL1, sunny.CurrentL2, sunny.CurrentL3} { + currents[i] = sma.AsFloat(values[id]) } - return vals[0], vals[1], vals[2], err + return currents[0], currents[1], currents[2], err } // soc implements the api.Battery interface func (sm *SMA) soc() (float64, error) { - values, err := sm.hasValue() - return sm.asFloat(values[sunny.BatteryCharge]), err + values, err := sm.device.Values() + return sma.AsFloat(values[sunny.BatteryCharge]), err } var _ api.Diagnosis = (*SMA)(nil) @@ -275,7 +152,7 @@ func (sm *SMA) Diagnose() { } fmt.Fprintln(w) - if values, err := sm.device.GetValues(); err == nil { + if values, err := sm.device.Values(); err == nil { ids := make([]sunny.ValueID, 0, len(values)) for k := range values { ids = append(ids, k) @@ -296,23 +173,3 @@ func (sm *SMA) Diagnose() { } w.Flush() } - -func (sm *SMA) asFloat(value interface{}) float64 { - switch v := value.(type) { - case float64: - return v - case int32: - return float64(v) - case int64: - return float64(v) - case uint32: - return float64(v) - case uint64: - return float64(v) - case nil: - return 0 - default: - sm.log.WARN.Printf("unknown value type: %T", value) - return 0 - } -} diff --git a/provider/sma.go b/provider/sma.go new file mode 100644 index 000000000..27a76d0a0 --- /dev/null +++ b/provider/sma.go @@ -0,0 +1,96 @@ +package provider + +import ( + "errors" + "fmt" + + "github.com/andig/evcc/provider/sma" + "github.com/andig/evcc/util" + "gitlab.com/bboehmke/sunny" +) + +// SMA provider +type SMA struct { + device *sma.Device + value sunny.ValueID + scale float64 +} + +func init() { + registry.Add("sma", NewSMAFromConfig) +} + +// NewSMAFromConfig creates SMA provider +func NewSMAFromConfig(other map[string]interface{}) (IntProvider, error) { + cc := struct { + URI, Password, Interface string + Serial uint32 + Value string + Scale float64 + }{ + Password: "0000", + Scale: 1, + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + discoverer, err := sma.GetDiscoverer(cc.Interface) + if err != nil { + return nil, fmt.Errorf("failed to get discoverer failed: %w", err) + } + + var provider = &SMA{ + scale: cc.Scale, + } + switch { + case cc.URI != "": + provider.device, err = discoverer.DeviceByIP(cc.URI, cc.Password) + if err != nil { + return nil, err + } + + case cc.Serial > 0: + provider.device = discoverer.DeviceBySerial(cc.Serial, cc.Password) + if provider.device == nil { + return nil, fmt.Errorf("device not found: %d", cc.Serial) + } + + default: + return nil, errors.New("missing uri or serial") + } + + provider.value, err = sunny.ValueIDString(cc.Value) + if err != nil { + return nil, err + } + + return provider, err +} + +// FloatGetter creates handler for float64 +func (p *SMA) FloatGetter() func() (float64, error) { + return func() (float64, error) { + values, err := p.device.Values() + if err != nil { + return 0, err + } + + return sma.AsFloat(values[p.value]) * p.scale, nil + } +} + +// IntGetter creates handler for int64 +func (p *SMA) IntGetter() func() (int64, error) { + fl := p.FloatGetter() + + return func() (int64, error) { + f, err := fl() + if err != nil { + return 0, err + } + + return int64(f), nil + } +} diff --git a/provider/sma/device.go b/provider/sma/device.go new file mode 100644 index 000000000..61dfeddce --- /dev/null +++ b/provider/sma/device.go @@ -0,0 +1,62 @@ +package sma + +import ( + "fmt" + "time" + + "github.com/andig/evcc/util" + "github.com/imdario/mergo" + "gitlab.com/bboehmke/sunny" +) + +// Device holds information for a Device and provides interface to get values +type Device struct { + *sunny.Device + + log *util.Logger + mux *util.Waiter + values map[sunny.ValueID]interface{} +} + +func (d *Device) updateValues() error { + d.mux.Lock() + defer d.mux.Unlock() + + values, err := d.Device.GetValues() + if err == nil { + err = mergo.Merge(&d.values, values, mergo.WithOverride) + d.mux.Update() + } + return err +} + +func (d *Device) Values() (map[sunny.ValueID]interface{}, error) { + elapsed := d.mux.LockWithTimeout() + defer d.mux.Unlock() + + if elapsed > 0 { + return nil, fmt.Errorf("update timeout: %v", elapsed.Truncate(time.Second)) + } + + return d.values, nil +} + +func AsFloat(value interface{}) float64 { + switch v := value.(type) { + case float64: + return v + case int32: + return float64(v) + case int64: + return float64(v) + case uint32: + return float64(v) + case uint64: + return float64(v) + case nil: + return 0 + default: + util.NewLogger("sma").WARN.Printf("unknown value type: %T", value) + return 0 + } +} diff --git a/provider/sma/discover.go b/provider/sma/discover.go new file mode 100644 index 000000000..82fee8418 --- /dev/null +++ b/provider/sma/discover.go @@ -0,0 +1,163 @@ +package sma + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "time" + + "github.com/andig/evcc/util" + "gitlab.com/bboehmke/sunny" +) + +const udpTimeout = 10 * time.Second + +// map of created discover instances +var discoverers = make(map[string]*Discoverer) +var discoverersMutex sync.Mutex + +// initialize sunny logger only once +var once sync.Once + +// GetDiscoverer fo the given interface +func GetDiscoverer(iface string) (*Discoverer, error) { + // on time initialization of sunny logger + log := util.NewLogger("sma") + once.Do(func() { + sunny.Log = log.TRACE + }) + + discoverersMutex.Lock() + defer discoverersMutex.Unlock() + + // get or create discoverer + discoverer, ok := discoverers[iface] + if !ok { + conn, err := sunny.NewConnection(iface) + if err != nil { + return nil, fmt.Errorf("connection failed: %w", err) + } + + discoverer = &Discoverer{ + log: log, + conn: conn, + devices: make(map[uint32]*Device), + } + + go discoverer.run() + + discoverers[iface] = discoverer + } + return discoverer, nil +} + +// Discoverer discovers SMA devicesBySerial in background while providing already found devicesBySerial +type Discoverer struct { + log *util.Logger + conn *sunny.Connection + devices map[uint32]*Device + mux sync.RWMutex + done uint32 +} + +func (d *Discoverer) createDevice(device *sunny.Device) *Device { + dev := &Device{ + Device: device, + log: d.log, + mux: util.NewWaiter(udpTimeout, func() { d.log.TRACE.Println("wait for initial value") }), + values: make(map[sunny.ValueID]interface{}), + } + + go func() { + for range time.NewTicker(time.Second).C { + err := dev.updateValues() + if err != nil { + d.log.ERROR.Println(err) + } + } + }() + + return dev +} + +func (d *Discoverer) addDevice(device *sunny.Device) { + d.mux.Lock() + defer d.mux.Unlock() + + if _, ok := d.devices[device.SerialNumber()]; !ok { + d.devices[device.SerialNumber()] = d.createDevice(device) + } +} + +// run discover and store found devicesBySerial +func (d *Discoverer) run() { + devices := make(chan *sunny.Device) + + go func() { + for device := range devices { + d.addDevice(device) + } + }() + + // discover devicesBySerial and wait for results + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + d.conn.DiscoverDevices(ctx, devices, "") + cancel() + close(devices) + + // mark discover as done + atomic.AddUint32(&d.done, 1) +} + +func (d *Discoverer) get(serial uint32, password string) *Device { + d.mux.RLock() + defer d.mux.RUnlock() + + device := d.devices[serial] + if device != nil { + device.SetPassword(password) + } + return device +} + +// DeviceBySerial with the given serial number +func (d *Discoverer) DeviceBySerial(serial uint32, password string) *Device { + start := time.Now() + for time.Since(start) < time.Second*3 { + // discover done -> return immediately regardless of result + if atomic.LoadUint32(&d.done) != 0 { + return d.get(serial, password) + } + + // device with serial found -> return + if device := d.get(serial, password); device != nil { + return device + } + + time.Sleep(time.Millisecond * 10) + } + return d.get(serial, password) +} + +// DeviceByIP with the given serial number +func (d *Discoverer) DeviceByIP(ip, password string) (*Device, error) { + d.mux.Lock() + defer d.mux.Unlock() + + for _, device := range d.devices { + if device.Address().IP.String() == ip { + device.SetPassword(password) + return device, nil + } + } + + device, err := d.conn.NewDevice(ip, password) + if err != nil { + return nil, fmt.Errorf("failed to get device: %w", err) + } + + dev := d.createDevice(device) + d.devices[device.SerialNumber()] = dev + return dev, err +}