Skip to content

Commit

Permalink
Add SMA/Speedwire plugin (evcc-io#1173)
Browse files Browse the repository at this point in the history
  • Loading branch information
bboehmke authored Jul 4, 2021
1 parent 4b01794 commit ae764c1
Show file tree
Hide file tree
Showing 5 changed files with 370 additions and 168 deletions.
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
191 changes: 24 additions & 167 deletions meter/sma.go
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down Expand Up @@ -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")
Expand All @@ -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
}
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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
}
}
Loading

0 comments on commit ae764c1

Please sign in to comment.