Add SMA/Speedwire plugin (#1173)
Some checks failed
Build / Build (push) Failing after 6m26s
Build / Build UI (push) Failing after 1m35s
Build / Publish :latest (push) Has been skipped
Build / Publish :release (push) Has been skipped
Build / Release (push) Has been skipped

This commit is contained in:
Benjamin Böhmke 2021-07-04 12:47:30 +02:00 • committed by GitHub
parent 4b017949d1
commit ae764c19ec
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 370 additions and 168 deletions

View file

@ -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:

View file

@ -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
}
}

96
provider/sma.go Normal file
View file

@ -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
}
}

62
provider/sma/device.go Normal file
View file

@ -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
}
}

163
provider/sma/discover.go Normal file
View file

@ -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
}