Skip to content

Commit 7bc8727

Browse files
authored
Merge pull request #171 from andig/feature/websocket
Add websocket handler
2 parents 4f033fb + 7ecfe9d commit 7bc8727

File tree

8 files changed

+372
-88
lines changed

8 files changed

+372
-88
lines changed

README.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ EVCC is an extensible EV Charge Controller with PV integration implemented in [G
3434
- [MQTT](#mqtt-readwrite)
3535
- [Script](#script-readwrite)
3636
- [HTTP](#http-readwrite)
37+
- [Websocket](#websocket-read-only)
3738
- [Combined status](#combined-status-read-only)
3839
- [Developer information](#developer-information)
3940
- [Background](#background)
@@ -374,7 +375,7 @@ Sample configuration:
374375
type: mqtt
375376
topic: mbmd/sdm1-1/Power
376377
timeout: 30s # don't accept values older than timeout
377-
scale: 0.001 # floating point factor applied to result, e.g. for kW to W conversion
378+
scale: 0.001 # floating point factor applied to result, e.g. for Wh to kWh conversion
378379
```
379380

380381
Sample write configuration:
@@ -435,6 +436,20 @@ Sample write configuration:
435436
body: %v # only applicable for PUT or POST requests
436437
```
437438

439+
### Websocket (read only)
440+
441+
The `websocket` plugin implements a web socket listener. Includes the ability to read and parse JSON using jq-like queries. It can for example be used to receive messages from Volkszähler's push server.
442+
443+
Sample configuration (read only):
444+
445+
```yaml
446+
type: http
447+
uri: ws://<volkszaehler host:port>/socket
448+
jq: .data | select(.uuid=="<uuid>") .tuples[0][1] # parse message json
449+
scale: 0.001 # floating point factor applied to result, e.g. for Wh to kWh conversion
450+
timeout: 30s # error if no update received in 30 seconds
451+
```
452+
438453
### Combined status (read only)
439454

440455
The `combined` status plugin is used to convert a mixed boolean status of plugged/charging into an EVCC-compatible charger status of A..F. It is typically used together with OpenWB MQTT integration.

meter/sma.go

Lines changed: 53 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
package meter
22

33
import (
4-
"errors"
4+
"fmt"
55
"sync"
66
"time"
77

@@ -15,22 +15,27 @@ const (
1515
waitTimeout = 50 * time.Millisecond // interval when waiting for initial value
1616
)
1717

18-
// SMA supporting SMA Home Manager 2.0 and SMA Energy Meter 30
19-
type SMA struct {
20-
log *util.Logger
21-
uri string
22-
serial string
18+
// values bundles SMA readings
19+
type values struct {
2320
power float64
2421
energy float64
2522
currentL1 float64
2623
currentL2 float64
2724
currentL3 float64
28-
powerO sma.Obis
29-
energyO sma.Obis
30-
updated time.Time
31-
recv chan sma.Telegram
32-
mux sync.Mutex
33-
once sync.Once
25+
}
26+
27+
// SMA supporting SMA Home Manager 2.0 and SMA Energy Meter 30
28+
type SMA struct {
29+
log *util.Logger
30+
uri string
31+
serial string
32+
values values
33+
powerO sma.Obis
34+
energyO sma.Obis
35+
updated time.Time
36+
recv chan sma.Telegram
37+
mux sync.Mutex
38+
once sync.Once
3439
}
3540

3641
// NewSMAFromConfig creates a SMA Meter from generic config
@@ -79,69 +84,51 @@ func NewSMA(uri, serial, power, energy string) api.Meter {
7984
return sm
8085
}
8186

82-
// waitForInitialValue makes sure we don't start with an error
83-
func (sm *SMA) waitForInitialValue() {
84-
sm.mux.Lock()
85-
defer sm.mux.Unlock()
86-
87-
if sm.updated.IsZero() {
88-
sm.log.TRACE.Print("waiting for initial value")
89-
90-
// wait for initial update
91-
for sm.updated.IsZero() {
92-
sm.mux.Unlock()
93-
time.Sleep(waitTimeout)
94-
sm.mux.Lock()
95-
}
96-
}
97-
}
98-
9987
// update the actual meter data
10088
func (sm *SMA) updateMeterValues(msg sma.Telegram) {
10189
sm.mux.Lock()
90+
defer sm.mux.Unlock()
10291

10392
if sm.powerO != "" {
10493
// use user-defined obis
10594
if power, ok := msg.Values[sm.powerO]; ok {
106-
sm.power = power
95+
sm.values.power = power
10796
sm.updated = time.Now()
10897
}
10998
} else {
110-
sm.power = msg.Values[sma.ImportPower] - msg.Values[sma.ExportPower]
99+
sm.values.power = msg.Values[sma.ImportPower] - msg.Values[sma.ExportPower]
111100
sm.updated = time.Now()
112101
}
113102

114103
if sm.energyO != "" {
115104
if energy, ok := msg.Values[sm.energyO]; ok {
116-
sm.energy = energy
105+
sm.values.energy = energy
117106
sm.updated = time.Now()
118107
} else {
119108
sm.log.WARN.Println("missing obis for energy")
120109
}
121110
}
122111

123112
if currentL1, ok := msg.Values[sma.CurrentL1]; ok {
124-
sm.currentL1 = currentL1
113+
sm.values.currentL1 = currentL1
125114
sm.updated = time.Now()
126115
} else {
127116
sm.log.WARN.Println("missing obis for currentL1")
128117
}
129118

130119
if currentL2, ok := msg.Values[sma.CurrentL2]; ok {
131-
sm.currentL2 = currentL2
120+
sm.values.currentL2 = currentL2
132121
sm.updated = time.Now()
133122
} else {
134123
sm.log.WARN.Println("missing obis for currentL2")
135124
}
136125

137126
if currentL3, ok := msg.Values[sma.CurrentL3]; ok {
138-
sm.currentL3 = currentL3
127+
sm.values.currentL3 = currentL3
139128
sm.updated = time.Now()
140129
} else {
141130
sm.log.WARN.Println("missing obis for currentL3")
142131
}
143-
144-
sm.mux.Unlock()
145132
}
146133

147134
// receive processes the channel message containing the multicast data
@@ -155,30 +142,45 @@ func (sm *SMA) receive() {
155142
}
156143
}
157144

158-
// CurrentPower implements the Meter.CurrentPower interface
159-
func (sm *SMA) CurrentPower() (float64, error) {
160-
sm.once.Do(sm.waitForInitialValue)
145+
// waitForInitialValue makes sure we don't start with an error
146+
func (sm *SMA) waitForInitialValue() {
161147
sm.mux.Lock()
162148
defer sm.mux.Unlock()
163149

164-
if time.Since(sm.updated) > udpTimeout {
165-
return 0, errors.New("recv timeout")
166-
}
150+
if sm.updated.IsZero() {
151+
sm.log.TRACE.Print("waiting for initial value")
167152

168-
return sm.power, nil
153+
// wait for initial update
154+
for sm.updated.IsZero() {
155+
sm.mux.Unlock()
156+
time.Sleep(waitTimeout)
157+
sm.mux.Lock()
158+
}
159+
}
169160
}
170161

171-
// Currents implements the MeterCurrent interface
172-
func (sm *SMA) Currents() (float64, float64, float64, error) {
162+
func (sm *SMA) hasValue() (values, error) {
173163
sm.once.Do(sm.waitForInitialValue)
174164
sm.mux.Lock()
175165
defer sm.mux.Unlock()
176166

177-
if time.Since(sm.updated) > udpTimeout {
178-
return 0, 0, 0, errors.New("recv timeout")
167+
if elapsed := time.Since(sm.updated); elapsed > udpTimeout {
168+
return values{}, fmt.Errorf("recv timeout: %v", elapsed.Truncate(time.Second))
179169
}
180170

181-
return sm.currentL1, sm.currentL2, sm.currentL3, nil
171+
return sm.values, nil
172+
}
173+
174+
// CurrentPower implements the Meter.CurrentPower interface
175+
func (sm *SMA) CurrentPower() (float64, error) {
176+
values, err := sm.hasValue()
177+
return values.power, err
178+
}
179+
180+
// Currents implements the MeterCurrent interface
181+
func (sm *SMA) Currents() (float64, float64, float64, error) {
182+
values, err := sm.hasValue()
183+
return values.currentL1, sm.values.currentL2, sm.values.currentL3, err
182184
}
183185

184186
// SMAEnergy decorates SMA with api.MeterEnergy interface
@@ -188,13 +190,6 @@ type SMAEnergy struct {
188190

189191
// TotalEnergy implements the api.MeterEnergy interface
190192
func (sm *SMAEnergy) TotalEnergy() (float64, error) {
191-
sm.once.Do(sm.waitForInitialValue)
192-
sm.mux.Lock()
193-
defer sm.mux.Unlock()
194-
195-
if time.Since(sm.updated) > udpTimeout {
196-
return 0, errors.New("recv timeout")
197-
}
198-
199-
return sm.energy, nil
193+
values, err := sm.hasValue()
194+
return values.energy, err
200195
}

meter/sma_test.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,20 +56,20 @@ func TestSMAUpdateMeterValues(t *testing.T) {
5656
}
5757

5858
sm.updateMeterValues(tt.messsage)
59-
if sm.power != tt.wantPower {
60-
t.Errorf("Listener.processMessage() got Power %v, want %v", sm.power, tt.wantPower)
59+
if sm.values.power != tt.wantPower {
60+
t.Errorf("Listener.processMessage() got Power %v, want %v", sm.values.power, tt.wantPower)
6161
}
6262

63-
if sm.currentL1 != tt.wantCurrentL1 {
64-
t.Errorf("Listener.processMessage() got CurrentL1 %v, want %v", sm.currentL1, tt.wantCurrentL1)
63+
if sm.values.currentL1 != tt.wantCurrentL1 {
64+
t.Errorf("Listener.processMessage() got CurrentL1 %v, want %v", sm.values.currentL1, tt.wantCurrentL1)
6565
}
6666

67-
if sm.currentL2 != tt.wantCurrentL2 {
68-
t.Errorf("Listener.processMessage() got CurrentL2 %v, want %v", sm.currentL2, tt.wantCurrentL2)
67+
if sm.values.currentL2 != tt.wantCurrentL2 {
68+
t.Errorf("Listener.processMessage() got CurrentL2 %v, want %v", sm.values.currentL2, tt.wantCurrentL2)
6969
}
7070

71-
if sm.currentL3 != tt.wantCurrentL3 {
72-
t.Errorf("Listener.processMessage() got CurrentL3 %v, want %v", sm.currentL3, tt.wantCurrentL3)
71+
if sm.values.currentL3 != tt.wantCurrentL3 {
72+
t.Errorf("Listener.processMessage() got CurrentL3 %v, want %v", sm.values.currentL3, tt.wantCurrentL3)
7373
}
7474

7575
})

provider/config.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ func NewFloatGetterFromConfig(log *util.Logger, config Config) (res FloatGetter)
6767
res = NewCalcFromConfig(log, config.Other)
6868
case "http":
6969
res = NewHTTPProviderFromConfig(log, config.Other).FloatGetter
70+
case "websocket", "ws":
71+
res = NewSocketProviderFromConfig(log, config.Other).FloatGetter
7072
case "mqtt":
7173
pc := mqttFromConfig(log, config.Other)
7274
res = MQTT.FloatGetter(pc.Topic, pc.Scale, pc.Timeout)
@@ -90,6 +92,8 @@ func NewIntGetterFromConfig(log *util.Logger, config Config) (res IntGetter) {
9092
switch strings.ToLower(config.Type) {
9193
case "http":
9294
res = NewHTTPProviderFromConfig(log, config.Other).IntGetter
95+
case "websocket", "ws":
96+
res = NewSocketProviderFromConfig(log, config.Other).IntGetter
9397
case "mqtt":
9498
pc := mqttFromConfig(log, config.Other)
9599
res = MQTT.IntGetter(pc.Topic, int64(pc.Scale), pc.Timeout)
@@ -113,6 +117,8 @@ func NewStringGetterFromConfig(log *util.Logger, config Config) (res StringGette
113117
switch strings.ToLower(config.Type) {
114118
case "http":
115119
res = NewHTTPProviderFromConfig(log, config.Other).StringGetter
120+
case "websocket", "ws":
121+
res = NewSocketProviderFromConfig(log, config.Other).StringGetter
116122
case "mqtt":
117123
pc := mqttFromConfig(log, config.Other)
118124
res = MQTT.StringGetter(pc.Topic, pc.Timeout)
@@ -136,6 +142,8 @@ func NewBoolGetterFromConfig(log *util.Logger, config Config) (res BoolGetter) {
136142
switch strings.ToLower(config.Type) {
137143
case "http":
138144
res = NewHTTPProviderFromConfig(log, config.Other).BoolGetter
145+
case "websocket", "ws":
146+
res = NewSocketProviderFromConfig(log, config.Other).BoolGetter
139147
case "mqtt":
140148
pc := mqttFromConfig(log, config.Other)
141149
res = MQTT.BoolGetter(pc.Topic, pc.Timeout)

provider/http.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import (
1717

1818
// HTTP implements HTTP request provider
1919
type HTTP struct {
20-
log *util.Logger
2120
*util.HTTPHelper
2221
url, method string
2322
headers map[string]string
@@ -57,7 +56,6 @@ func NewHTTPProviderFromConfig(log *util.Logger, other map[string]interface{}) *
5756
logger := util.NewLogger("http")
5857

5958
p := &HTTP{
60-
log: logger,
6159
HTTPHelper: util.NewHTTPHelper(logger),
6260
url: cc.URI,
6361
method: cc.Method,

provider/mqtt.go

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -249,21 +249,30 @@ func (h *msgHandler) waitForInitialValue() {
249249
}
250250
}
251251

252-
func (h *msgHandler) floatGetter() (float64, error) {
252+
func (h *msgHandler) hasValue() (string, error) {
253253
h.once.Do(h.waitForInitialValue)
254254
h.mux.Lock()
255255
defer h.mux.Unlock()
256256

257257
if elapsed := time.Since(h.updated); h.timeout != 0 && elapsed > h.timeout {
258-
return 0, fmt.Errorf("%s outdated: %v", h.topic, elapsed.Truncate(time.Second))
258+
return "", fmt.Errorf("%s outdated: %v", h.topic, elapsed.Truncate(time.Second))
259259
}
260260

261-
val, err := strconv.ParseFloat(h.payload, 64)
261+
return h.payload, nil
262+
}
263+
264+
func (h *msgHandler) floatGetter() (float64, error) {
265+
v, err := h.hasValue()
262266
if err != nil {
263-
return 0, fmt.Errorf("%s invalid: '%s'", h.topic, h.payload)
267+
return 0, err
264268
}
265269

266-
return h.scale * val, nil
270+
f, err := strconv.ParseFloat(v, 64)
271+
if err != nil {
272+
return 0, fmt.Errorf("%s invalid: '%s'", h.topic, v)
273+
}
274+
275+
return f * h.scale, nil
267276
}
268277

269278
func (h *msgHandler) intGetter() (int64, error) {
@@ -272,25 +281,19 @@ func (h *msgHandler) intGetter() (int64, error) {
272281
}
273282

274283
func (h *msgHandler) stringGetter() (string, error) {
275-
h.once.Do(h.waitForInitialValue)
276-
h.mux.Lock()
277-
defer h.mux.Unlock()
278-
279-
if elapsed := time.Since(h.updated); h.timeout != 0 && elapsed > h.timeout {
280-
return "", fmt.Errorf("%s outdated: %v", h.topic, elapsed.Truncate(time.Second))
284+
v, err := h.hasValue()
285+
if err != nil {
286+
return "", err
281287
}
282288

283-
return string(h.payload), nil
289+
return string(v), nil
284290
}
285291

286292
func (h *msgHandler) boolGetter() (bool, error) {
287-
h.once.Do(h.waitForInitialValue)
288-
h.mux.Lock()
289-
defer h.mux.Unlock()
290-
291-
if elapsed := time.Since(h.updated); h.timeout != 0 && elapsed > h.timeout {
292-
return false, fmt.Errorf("%s outdated: %v", h.topic, elapsed.Truncate(time.Second))
293+
v, err := h.hasValue()
294+
if err != nil {
295+
return false, err
293296
}
294297

295-
return util.Truish(string(h.payload)), nil
298+
return util.Truish(v), nil
296299
}

0 commit comments

Comments
 (0)