Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added support for SMA Meters #59

Merged
merged 14 commits into from
Apr 27, 2020
6 changes: 6 additions & 0 deletions evcc.dist.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ meters:
type: script # use script
cmd: /bin/sh -c "echo 0" # actual command
timeout: 3s # kill script after 3 seconds
#- name: grid
# type: smameter # SMA Home Manager 2.0 or SMA Energy Meter 30
# uri: 192.168.1.4 # IP Address of the device
#- name: pv
# type: smameter # SMA Home Manager 2.0 or SMA Energy Meter 30
# uri: 192.168.1.4 # IP Address of the device

chargers:
- name: wallbe
Expand Down
2 changes: 2 additions & 0 deletions meter/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ func NewFromConfig(log *api.Logger, typ string, other map[string]interface{}) ap
switch strings.ToLower(typ) {
case "default", "configurable":
c = NewConfigurableFromConfig(log, other)
case "smameter":
c = NewSMAFromConfig(log, other)
default:
log.FATAL.Fatalf("invalid meter type '%s'", typ)
}
Expand Down
82 changes: 82 additions & 0 deletions meter/sma.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package meter

import (
"errors"
"time"

"github.com/andig/evcc/api"
"github.com/andig/evcc/meter/sma"
)

const (
udpTimeout = 10 * time.Second
)

// SMA supporting SMA Home Manager 2.0 and SMA Energy Meter 30
type SMA struct {
uri string
power float64
lastUpdate time.Time
recv chan sma.TelegramData
}

// NewSMAFromConfig creates a SMA Meter from generic config
func NewSMAFromConfig(log *api.Logger, other map[string]interface{}) api.Meter {
sm := struct {
URI string
}{}
api.DecodeOther(log, other, &sm)

return NewSMA(sm.URI)
}

// NewSMA creates a SMA Meter
func NewSMA(uri string) *SMA {
log := api.NewLogger("sma ")

sm := &SMA{
uri: uri,
recv: make(chan sma.TelegramData),
}

if sma.Instance == nil {
sma.Instance = sma.New(log, sm.uri)
}

sma.Instance.Subscribe(uri, sm.recv)

sm.lastUpdate = time.Now()
DerAndereAndi marked this conversation as resolved.
Show resolved Hide resolved
go sm.receive()

return sm
}

// receive processes the channel message containing the multicast data
func (sm *SMA) receive() {
for msg := range sm.recv {
if msg.Data == nil {
continue
}

powerIn, ok1 := msg.Data[sma.ObisImportPower]
DerAndereAndi marked this conversation as resolved.
Show resolved Hide resolved
powerOut, ok2 := msg.Data[sma.ObisExportPower]

if ok1 || ok2 {
sm.lastUpdate = time.Now()
if powerOut > 0 {
sm.power = -powerOut
} else {
sm.power = powerIn
}
}
}
}

// CurrentPower implements the Meter.CurrentPower interface
func (sm *SMA) CurrentPower() (float64, error) {
if time.Since(sm.lastUpdate) > udpTimeout {
return 0, errors.New("recv timeout")
}

return sm.power, nil
}
DerAndereAndi marked this conversation as resolved.
Show resolved Hide resolved
221 changes: 221 additions & 0 deletions meter/sma/listener.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
package sma

import (
"encoding/binary"
"errors"
"fmt"
"net"
"strconv"
"sync"

"github.com/andig/evcc/api"
)

const (
multicastAddr = "239.12.255.254:9522"
udpBufferSize = 8192
obisCodeLength = 4
ObisImportPower = "1:1.4.0" // Wirkleistung (W)
ObisImportEnergy = "1:1.8.0" // Wirkarbeit (Ws) +
ObisExportPower = "1:2.4.0" // Wirkleistung (W)
ObisExportEnergy = "1:2.8.0" // Wirkarbeit (Ws) −
)

// obisCodeProp defines the properties needed to parse the SMA multicast telegram values
type obisCodeProp struct {
length int // data size in bytes of the return value
factor float64 // the factor to multiply the value by to get the proper value in the given unit
}

// list of Obis codes and their properties as defined in the SMA EMETER-Protokoll-TI-de-10.pdf document
var knownObisCodes = map[string]obisCodeProp{
// Overal sums
ObisImportPower: {4, 0.1}, ObisImportEnergy: {8, 1}, // Wirkleistung (W)/-arbeit (Ws) +
ObisExportPower: {4, 0.1}, ObisExportEnergy: {8, 1}, // Wirkleistung (W)/-arbeit (Ws) −
"1:3.4.0": {4, 0.1}, "1:3.8.0": {8, 1}, // Blindleistung (W)/-arbeit (Ws) +
"1:4.4.0": {4, 0.1}, "1:4.8.0": {8, 1}, // Blindleistung (W)/-arbeit (Ws) −
"1:9.4.0": {4, 0.1}, "1:9.8.0": {8, 1}, // Scheinleistung (W)/-arbeit (Ws) +
"1:10.4.0": {4, 0.1}, "1:10.8.0": {8, 1}, // Scheinleistung (W)/-arbeit (Ws) −
"1:13.4.0": {4, 0.001}, // Leistungsfaktor (Φ)
// Phase 1: {
"1:21.4.0": {4, 0.1}, "1:21.8.0": {8, 1}, // Wirkleistung (W)/-arbeit (Ws) +
"1:22.4.0": {4, 0.1}, "1:22.8.0": {8, 1}, // Wirkleistung (W)/-arbeit (Ws) −
"1:23.4.0": {4, 0.1}, "1:23.8.0": {8, 1}, // Blindleistung (W)/-arbeit (Ws) +
"1:24.4.0": {4, 0.1}, "1:24.8.0": {8, 1}, // Blindleistung (W)/-arbeit (Ws) −
"1:29.4.0": {4, 0.1}, "1:29.8.0": {8, 1}, // Scheinleistung (W)/-arbeit (Ws) +
"1:30.4.0": {4, 0.1}, "1:30.8.0": {8, 1}, // Scheinleistung (W)/-arbeit (Ws) −
"1:31.4.0": {4, 0.001}, // Strom (A)
"1:32.4.0": {4, 0.001}, // Spannung (V
// Phase 2: {
"1:41.4.0": {4, 0.1}, "1:41.8.0": {8, 1}, // Wirkleistung (W)/-arbeit (Ws) +
"1:42.4.0": {4, 0.1}, "1:42.8.0": {8, 1}, // Wirkleistung (W)/-arbeit (Ws) −
"1:43.4.0": {4, 0.1}, "1:43.8.0": {8, 1}, // Blindleistung (W)/-arbeit (Ws) +
"1:44.4.0": {4, 0.1}, "1:44.8.0": {8, 1}, // Blindleistung (W)/-arbeit (Ws) −
"1:49.4.0": {4, 0.1}, "1:49.8.0": {8, 1}, // Scheinleistung (W)/-arbeit (Ws) +
"1:50.4.0": {4, 0.1}, "1:50.8.0": {8, 1}, // Scheinleistung (W)/-arbeit (Ws) −
"1:51.4.0": {4, 0.001}, // Strom (A)
"1:52.4.0": {4, 0.001}, // Spannung (V)
// Phase 3: {
"1:61.4.0": {4, 0.1}, "1:61.8.0": {8, 1}, // Wirkleistung (W)/-arbeit (Ws) +
"1:62.4.0": {4, 0.1}, "1:62.8.0": {8, 1}, // Wirkleistung (W)/-arbeit (Ws) −
"1:63.4.0": {4, 0.1}, "1:63.8.0": {8, 1}, // Blindleistung (W)/-arbeit (Ws) +
"1:64.4.0": {4, 0.1}, "1:64.8.0": {8, 1}, // Blindleistung (W)/-arbeit (Ws) −
"1:69.4.0": {4, 0.1}, "1:69.8.0": {8, 1}, // Scheinleistung (W)/-arbeit (Ws) +
"1:70.4.0": {4, 0.1}, "1:70.8.0": {8, 1}, // Scheinleistung (W)/-arbeit (Ws) −
"1:71.4.0": {4, 0.001}, // Strom (A)
"1:72.4.0": {4, 0.001}, // Spannung (V)
// Others
"144:0.0.0": {4, 1}, // SW Version
}

var Instance *Listener

// TelegramData defines the data structure of a SMA multicast data package
type TelegramData struct {
Addr string
Serial string
Data map[string]float64
}

// Listener for receiving SMA multicast data packages
type Listener struct {
mux sync.Mutex
log *api.Logger
conn *net.UDPConn
clients map[string]chan<- TelegramData
}

// New creates a Listener
func New(log *api.Logger, addr string) *Listener {
// Parse the string address
laddr, err := net.ResolveUDPAddr("udp4", multicastAddr)
if err != nil {
log.FATAL.Fatalf("error resolving udp address: %s", err)
}

// Open up a connection
conn, err := net.ListenMulticastUDP("udp4", nil, laddr)
if err != nil {
log.FATAL.Fatalf("error opening connecting: %s", err)
}

if err := conn.SetReadBuffer(udpBufferSize); err != nil {
log.FATAL.Fatalf("error setting read buffer: %s", err)
}

l := &Listener{
log: log,
conn: conn,
}

go l.listen()

return l
}

// processUDPData converts a SMA Multicast data package into TelegramData
func (l *Listener) processUDPData(src *net.UDPAddr, buffer []byte) (TelegramData, error) {
numBytes := len(buffer)

serial := strconv.FormatUint(uint64(binary.BigEndian.Uint32(buffer[20:24])), 10)
DerAndereAndi marked this conversation as resolved.
Show resolved Hide resolved

obisCodeValues := make(map[string]float64)

// read obis code values, start at position 28, after initial static stuff
for i := 28; i < numBytes; i++ {
andig marked this conversation as resolved.
Show resolved Hide resolved
if i+obisCodeLength > numBytes-1 {
break
}
if obisCode, element, err := getObisCodeElement(buffer[i : i+obisCodeLength]); err == nil {
dataIndex := i + obisCodeLength

var value float64
switch element.length {
case 4:
value = float64(binary.BigEndian.Uint32(buffer[dataIndex : dataIndex+element.length+1]))
case 8:
value = float64(binary.BigEndian.Uint64(buffer[dataIndex : dataIndex+element.length+1]))
}

obisCodeValues[obisCode] = value * element.factor

i = dataIndex + element.length - 1
}
}

msg := TelegramData{
Addr: src.IP.String(),
Serial: serial,
Data: obisCodeValues,
}

return msg, nil
}

// listen for Multicast data packages
func (l *Listener) listen() {
buffer := make([]byte, udpBufferSize)
// Loop forever reading
for {
numBytes, src, err := l.conn.ReadFromUDP(buffer)
if err != nil {
l.log.WARN.Fatalf("readfromudp failed: %s", err)
DerAndereAndi marked this conversation as resolved.
Show resolved Hide resolved
continue
}

if msg, err := l.processUDPData(src, buffer[:numBytes-1]); err != nil {
l.send(msg)
}
}

DerAndereAndi marked this conversation as resolved.
Show resolved Hide resolved
}

// Subscribe adds a client address and message channel
func (l *Listener) Subscribe(addr string, c chan<- TelegramData) {
l.mux.Lock()
defer l.mux.Unlock()

if l.clients == nil {
l.clients = make(map[string]chan<- TelegramData)
}

l.clients[addr] = c
}

func (l *Listener) send(msg TelegramData) {
l.mux.Lock()
defer l.mux.Unlock()

for addr, client := range l.clients {
if addr == msg.Addr {
select {
case client <- msg:
default:
l.log.TRACE.Println("listener: recv blocked")
}
break
}
}
}

// getObisCodeElement parses the OBIS code from a set of bytes
func getObisCodeElement(value []byte) (string, obisCodeProp, error) {
b0 := value[3]
b1 := value[2]
b2 := value[1]
b3 := value[0]

// Spec says value should be 1, but reading contains 0
if b3 == 0 {
b3 = 1
}

code := fmt.Sprintf("%d:%d.%d.%d", b3, b2, b1, b0)
DerAndereAndi marked this conversation as resolved.
Show resolved Hide resolved
var element obisCodeProp = knownObisCodes[code]

if element.length > 0 {
return code, element, nil
}

return "", obisCodeProp{}, errors.New("no obis code found")
DerAndereAndi marked this conversation as resolved.
Show resolved Hide resolved
}
Loading