518 lines
15 KiB
Go
518 lines
15 KiB
Go
package meter
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/coder/websocket"
|
|
"github.com/evcc-io/evcc/api"
|
|
"github.com/evcc-io/evcc/api/implement"
|
|
"github.com/evcc-io/evcc/meter/obis"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// dsmrTelegram40 is the canonical DSMR 4.0 example telegram (nlrb/node-dsmr-parser
|
|
// example-telegram.txt). Its published CRC F46A — over the frame up to and
|
|
// including '!' with CRLF line endings — is an independent reference vector for
|
|
// crc16ARC. It carries multi-value, gas and intentionally malformed lines and no
|
|
// per-phase or combined energy registers, so no phase capability is offered.
|
|
const dsmrTelegram40 = `/ISk5\2MT382-1000
|
|
|
|
1-3:0.2.8(40)
|
|
0-0:1.0.0(101209113020W)
|
|
0-0:96.1.1(4B384547303034303436333935353037)
|
|
1-0:1.8.1(123456.789*kWh)
|
|
1-0:1.8.2(123456.789*kWh)
|
|
1-0:2.8.1(123456.789*kWh)
|
|
1-0:2.8.2(123456.789*kWh)
|
|
0-0:96.14.0(0002)
|
|
1-0:1.7.0(01.193*kW)
|
|
1-0:2.7.0(00.000*kW)
|
|
0-0:17.0.0(016.1*kW)
|
|
0-0:96.3.10(1)
|
|
0-0:96.7.21(00004)
|
|
0-0:96.7.9(00002)
|
|
1-0:99:97.0(2)(0:96.7.19)(101208152415W)(0000000240*s)(101208151004W)(00000000301*s)
|
|
1-0:32.32.0(00002)
|
|
1-0:52.32.0(00001)
|
|
1-0:72:32.0(00000)
|
|
1-0:32.36.0(00000)
|
|
1-0:52.36.0(00003)
|
|
1-0:72.36.0(00000)
|
|
0-0:96.13.1(3031203631203831)
|
|
0-0:96.13.0(303132333435363738393A3B3C3D3E3F303132333435363738393A3B3C3D3E3F303132333435363738393A3B3C3D3E3F303132333435363738393A3B3C3D3E3F303132333435363738393A3B3C3D3E3F)
|
|
0-1:24.1.0(03)
|
|
0-1:96.1.0(3232323241424344313233343536373839)
|
|
0-1:24.2.1(101209110000W)(12785.123*m3)
|
|
0-1:24.4.0(1)
|
|
!`
|
|
|
|
// dsmrTelegram50 is a real DSMR 5.0 telegram (nlrb/node-dsmr-parser
|
|
// example-v5.txt) with published CRC 3F8B. It carries all three phases of
|
|
// voltage, current and power, so every phase capability is offered, and energy
|
|
// via tariff registers (exercising the fallback).
|
|
const dsmrTelegram50 = `/ISK5\2M550T-1011
|
|
|
|
1-3:0.2.8(50)
|
|
0-0:1.0.0(170531201444S)
|
|
0-0:96.1.1(4530303334303036383234393130313137)
|
|
1-0:1.8.1(000000.626*kWh)
|
|
1-0:1.8.2(000002.564*kWh)
|
|
1-0:2.8.1(000000.000*kWh)
|
|
1-0:2.8.2(000000.000*kWh)
|
|
0-0:96.14.0(0002)
|
|
1-0:1.7.0(00.532*kW)
|
|
1-0:2.7.0(00.000*kW)
|
|
0-0:96.7.21(00004)
|
|
0-0:96.7.9(00002)
|
|
1-0:99.97.0()
|
|
1-0:32.32.0(00000)
|
|
1-0:52.32.0(00000)
|
|
1-0:72.32.0(00000)
|
|
1-0:32.36.0(00001)
|
|
1-0:52.36.0(00001)
|
|
1-0:72.36.0(00001)
|
|
0-0:96.13.0()
|
|
1-0:32.7.0(236.8*V)
|
|
1-0:52.7.0(236.1*V)
|
|
1-0:72.7.0(238.4*V)
|
|
1-0:31.7.0(000*A)
|
|
1-0:51.7.0(001*A)
|
|
1-0:71.7.0(001*A)
|
|
1-0:21.7.0(00.056*kW)
|
|
1-0:41.7.0(00.270*kW)
|
|
1-0:61.7.0(00.206*kW)
|
|
1-0:22.7.0(00.000*kW)
|
|
1-0:42.7.0(00.000*kW)
|
|
1-0:62.7.0(00.000*kW)
|
|
0-1:24.1.0(003)
|
|
0-1:96.1.0(4730303332353635353039393034313137)
|
|
0-1:24.2.1(170531201008S)(00000.038*m3)
|
|
!`
|
|
|
|
// dsmrTelegramADN is a real telegram from an ADN9 meter with published CRC
|
|
// 759E. It is the only example carrying combined 1.8.0/2.8.0 energy registers
|
|
// (exercising the non-fallback path) alongside reactive-power registers and all
|
|
// three phases.
|
|
const dsmrTelegramADN = `/ADN9 6534
|
|
|
|
0-0:1.0.0(240108164940W)
|
|
1-0:1.8.0(00010117.368*kWh)
|
|
1-0:2.8.0(00000000.000*kWh)
|
|
1-0:3.8.0(00001759.974*kVArh)
|
|
1-0:4.8.0(00001260.519*kVArh)
|
|
1-0:1.7.0(0004.251*kW)
|
|
1-0:2.7.0(0000.000*kW)
|
|
1-0:3.7.0(0001.279*kVAr)
|
|
1-0:4.7.0(0000.000*kVAr)
|
|
1-0:21.7.0(0001.462*kW)
|
|
1-0:22.7.0(0000.000*kW)
|
|
1-0:41.7.0(0001.263*kW)
|
|
1-0:42.7.0(0000.000*kW)
|
|
1-0:61.7.0(0001.523*kW)
|
|
1-0:62.7.0(0000.000*kW)
|
|
1-0:23.7.0(0000.529*kVAr)
|
|
1-0:24.7.0(0000.000*kVAr)
|
|
1-0:43.7.0(0000.500*kVAr)
|
|
1-0:44.7.0(0000.000*kVAr)
|
|
1-0:63.7.0(0000.269*kVAr)
|
|
1-0:64.7.0(0000.000*kVAr)
|
|
1-0:32.7.0(221.2*V)
|
|
1-0:52.7.0(224.3*V)
|
|
1-0:72.7.0(223.3*V)
|
|
1-0:31.7.0(007.0*A)
|
|
1-0:51.7.0(006.0*A)
|
|
1-0:71.7.0(006.9*A)
|
|
!`
|
|
|
|
// dsmrTelegramExport is a synthetic DSMR 4.2 telegram in which the meter is
|
|
// exporting: total and per-phase L3 export power are non-zero while import is
|
|
// zero. It exercises the sign-correction path no real example covers — negative
|
|
// CurrentPower, a negative L3 phase current (negated because 62.7.0 > 0) and a
|
|
// negative L3 phase power. Hand-crafted, so the CRC is computed, not published.
|
|
const dsmrTelegramExport = `/XMX5XMXAA10012039345
|
|
|
|
1-3:0.2.8(42)
|
|
0-0:1.0.0(260604130000W)
|
|
0-0:96.1.1(4530303539303030353433383330383031)
|
|
1-0:1.8.1(004512.345*kWh)
|
|
1-0:1.8.2(003241.123*kWh)
|
|
1-0:2.8.1(001234.567*kWh)
|
|
1-0:2.8.2(000453.210*kWh)
|
|
0-0:96.14.0(0002)
|
|
1-0:1.7.0(00.000*kW)
|
|
1-0:2.7.0(02.450*kW)
|
|
0-0:96.7.21(00002)
|
|
0-0:96.7.9(00000)
|
|
1-0:32.7.0(239.5*V)
|
|
1-0:52.7.0(241.1*V)
|
|
1-0:72.7.0(238.9*V)
|
|
1-0:31.7.0(005.1*A)
|
|
1-0:51.7.0(000.0*A)
|
|
1-0:71.7.0(004.8*A)
|
|
1-0:21.7.0(00.000*kW)
|
|
1-0:22.7.0(00.000*kW)
|
|
1-0:41.7.0(00.000*kW)
|
|
1-0:42.7.0(00.000*kW)
|
|
1-0:61.7.0(00.000*kW)
|
|
1-0:62.7.0(02.450*kW)
|
|
0-1:24.1.0(003)
|
|
0-1:24.2.1(260604124500S)(00845.123*m3)
|
|
!`
|
|
|
|
// dsmrFrame returns the telegram with a valid trailing CRC line, as a meter
|
|
// would receive it on the wire.
|
|
func dsmrFrame(telegram string) []byte {
|
|
frame := []byte(telegram)
|
|
crc := fmt.Sprintf("%04X", crc16ARC(frame))
|
|
return append(frame, []byte(crc+"\r\n")...)
|
|
}
|
|
|
|
// serveTCP streams payload on every accepted connection until the test ends.
|
|
func serveTCP(t *testing.T, payload []byte) string {
|
|
t.Helper()
|
|
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { ln.Close() })
|
|
|
|
go func() {
|
|
for {
|
|
conn, err := ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
go func() {
|
|
defer conn.Close()
|
|
for {
|
|
if _, err := conn.Write(payload); err != nil {
|
|
return
|
|
}
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
}()
|
|
}
|
|
}()
|
|
|
|
return ln.Addr().String()
|
|
}
|
|
|
|
// assertReadings checks the values decoded from dsmrTelegram50.
|
|
func assertReadings(t *testing.T, m api.Meter) {
|
|
t.Helper()
|
|
|
|
p, err := m.CurrentPower()
|
|
require.NoError(t, err)
|
|
require.Equal(t, 532.0, p)
|
|
|
|
me, ok := api.Cap[api.MeterEnergy](m)
|
|
require.True(t, ok, "MeterEnergy expected (tariff fallback)")
|
|
e, err := me.TotalEnergy()
|
|
require.NoError(t, err)
|
|
require.InDelta(t, 3.190, e, 1e-6) // 1.8.1 + 1.8.2
|
|
|
|
re, ok := api.Cap[api.MeterReturnEnergy](m)
|
|
require.True(t, ok, "MeterReturnEnergy expected (tariff fallback)")
|
|
r, err := re.ReturnEnergy()
|
|
require.NoError(t, err)
|
|
require.Equal(t, 0.0, r)
|
|
|
|
// all three phases are present, so every phase capability is offered
|
|
pc, ok := api.Cap[api.PhaseCurrents](m)
|
|
require.True(t, ok, "PhaseCurrents expected with all three phases")
|
|
i1, i2, i3, err := pc.Currents()
|
|
require.NoError(t, err)
|
|
require.Equal(t, []float64{0, 1, 1}, []float64{i1, i2, i3})
|
|
|
|
pv, ok := api.Cap[api.PhaseVoltages](m)
|
|
require.True(t, ok, "PhaseVoltages expected with all three phases")
|
|
u1, u2, u3, err := pv.Voltages()
|
|
require.NoError(t, err)
|
|
require.Equal(t, []float64{236.8, 236.1, 238.4}, []float64{u1, u2, u3})
|
|
|
|
pp, ok := api.Cap[api.PhasePowers](m)
|
|
require.True(t, ok, "PhasePowers expected with all three phases")
|
|
w1, w2, w3, err := pp.Powers()
|
|
require.NoError(t, err)
|
|
require.Equal(t, []float64{56, 270, 206}, []float64{w1, w2, w3})
|
|
}
|
|
|
|
func TestDsmrTCP(t *testing.T) {
|
|
addr := serveTCP(t, dsmrFrame(dsmrTelegram50))
|
|
|
|
ctx := t.Context()
|
|
|
|
m, err := NewDsmr(ctx, addr, time.Second)
|
|
require.NoError(t, err)
|
|
|
|
assertReadings(t, m)
|
|
}
|
|
|
|
func TestDsmrWebSocket(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
message websocket.MessageType
|
|
}{
|
|
{"text", websocket.MessageText},
|
|
{"binary", websocket.MessageBinary},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
payload := dsmrFrame(dsmrTelegram50)
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
conn, err := websocket.Accept(w, r, nil)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer conn.CloseNow()
|
|
|
|
for {
|
|
if err := conn.Write(r.Context(), tc.message, payload); err != nil {
|
|
return
|
|
}
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
uri := "ws" + strings.TrimPrefix(srv.URL, "http")
|
|
|
|
ctx := t.Context()
|
|
|
|
m, err := NewDsmr(ctx, uri, time.Second)
|
|
require.NoError(t, err)
|
|
|
|
assertReadings(t, m)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestDsmrIgnoresGarbage verifies that leading garbage and a CRC-mismatched
|
|
// frame are skipped while a subsequent valid frame is still parsed.
|
|
func TestDsmrIgnoresGarbage(t *testing.T) {
|
|
var payload []byte
|
|
payload = append(payload, []byte("some-garbage-bytes\r\n")...) // not a frame start
|
|
payload = append(payload, []byte(dsmrTelegram40)...) // frame ...
|
|
payload = append(payload, []byte("0000\r\n")...) // ... with wrong CRC
|
|
payload = append(payload, dsmrFrame(dsmrTelegram40)...) // valid frame
|
|
|
|
addr := serveTCP(t, payload)
|
|
|
|
ctx := t.Context()
|
|
|
|
m, err := NewDsmr(ctx, addr, time.Second)
|
|
require.NoError(t, err)
|
|
|
|
p, err := m.CurrentPower()
|
|
require.NoError(t, err)
|
|
require.Equal(t, 1193.0, p)
|
|
}
|
|
|
|
// TestDsmrConstructTimeoutStopsReader verifies that when the initial frame does
|
|
// not arrive before the constructor timeout, canceling the context (as the
|
|
// device setup does on a constructor error) stops the background read loop and
|
|
// closes the connection instead of leaking it.
|
|
func TestDsmrConstructTimeoutStopsReader(t *testing.T) {
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
require.NoError(t, err)
|
|
defer ln.Close()
|
|
|
|
closed := make(chan struct{})
|
|
go func() {
|
|
conn, err := ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
// never send a frame; the read returns once the client (run) closes on cancel
|
|
_, _ = conn.Read(make([]byte, 1))
|
|
close(closed)
|
|
}()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
_, err = NewDsmr(ctx, ln.Addr().String(), 200*time.Millisecond)
|
|
require.ErrorIs(t, err, os.ErrDeadlineExceeded)
|
|
|
|
// device setup cancels the context on a constructor error
|
|
cancel()
|
|
|
|
select {
|
|
case <-closed:
|
|
// run closed the connection => the goroutine is terminating
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("reader connection was not closed after context cancel")
|
|
}
|
|
}
|
|
|
|
// TestDsmrCRC validates crc16ARC against the published reference CRCs of two
|
|
// real example telegrams. The wire format uses CRLF line endings and the
|
|
// checksum covers the frame up to and including '!'.
|
|
func TestDsmrCRC(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
telegram string
|
|
crc uint16
|
|
}{
|
|
{"v4.0", dsmrTelegram40, 0xF46A},
|
|
{"v5.0", dsmrTelegram50, 0x3F8B},
|
|
{"adn9", dsmrTelegramADN, 0x759E},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
frame := strings.ReplaceAll(tc.telegram, "\n", "\r\n")
|
|
require.Equal(t, tc.crc, crc16ARC([]byte(frame)))
|
|
})
|
|
}
|
|
}
|
|
|
|
// newTestDsmr builds a Dsmr around a fixed frame, bypassing the transport, to
|
|
// unit-test the capability decoration and register-selection logic.
|
|
func newTestDsmr(frame map[string]string) *Dsmr {
|
|
m := &Dsmr{
|
|
Caps: implement.New(),
|
|
timeout: time.Minute,
|
|
updated: time.Now(),
|
|
frame: frame,
|
|
}
|
|
m.decorateEnergy()
|
|
m.decorateCurrents()
|
|
m.decorateVoltages()
|
|
m.decoratePowers()
|
|
return m
|
|
}
|
|
|
|
// TestDsmrADN reads the real ADN9 telegram end-to-end: combined 1.8.0/2.8.0
|
|
// energy registers and all three phases, decoded from a telegram interleaved
|
|
// with reactive-power registers the parser must ignore.
|
|
func TestDsmrADN(t *testing.T) {
|
|
addr := serveTCP(t, dsmrFrame(dsmrTelegramADN))
|
|
|
|
ctx := t.Context()
|
|
|
|
m, err := NewDsmr(ctx, addr, time.Second)
|
|
require.NoError(t, err)
|
|
|
|
p, err := m.CurrentPower()
|
|
require.NoError(t, err)
|
|
require.Equal(t, 4251.0, p)
|
|
|
|
me, ok := api.Cap[api.MeterEnergy](m)
|
|
require.True(t, ok, "MeterEnergy expected (combined register)")
|
|
e, err := me.TotalEnergy()
|
|
require.NoError(t, err)
|
|
require.InDelta(t, 10117.368, e, 1e-6)
|
|
|
|
re, ok := api.Cap[api.MeterReturnEnergy](m)
|
|
require.True(t, ok, "MeterReturnEnergy expected (combined register)")
|
|
r, err := re.ReturnEnergy()
|
|
require.NoError(t, err)
|
|
require.Equal(t, 0.0, r)
|
|
|
|
pc, ok := api.Cap[api.PhaseCurrents](m)
|
|
require.True(t, ok, "PhaseCurrents expected with all three phases")
|
|
i1, i2, i3, err := pc.Currents()
|
|
require.NoError(t, err)
|
|
require.Equal(t, []float64{7.0, 6.0, 6.9}, []float64{i1, i2, i3})
|
|
|
|
pv, ok := api.Cap[api.PhaseVoltages](m)
|
|
require.True(t, ok, "PhaseVoltages expected with all three phases")
|
|
u1, u2, u3, err := pv.Voltages()
|
|
require.NoError(t, err)
|
|
require.Equal(t, []float64{221.2, 224.3, 223.3}, []float64{u1, u2, u3})
|
|
|
|
pp, ok := api.Cap[api.PhasePowers](m)
|
|
require.True(t, ok, "PhasePowers expected with all three phases")
|
|
w1, w2, w3, err := pp.Powers()
|
|
require.NoError(t, err)
|
|
require.Equal(t, []float64{1462, 1263, 1523}, []float64{w1, w2, w3})
|
|
}
|
|
|
|
// TestDsmrExport reads an exporting meter and checks the sign-correction path:
|
|
// negative total power, and a negative L3 phase current and power (L3 export
|
|
// register 62.7.0 is non-zero), while the non-exporting phases stay positive.
|
|
func TestDsmrExport(t *testing.T) {
|
|
addr := serveTCP(t, dsmrFrame(dsmrTelegramExport))
|
|
|
|
ctx := t.Context()
|
|
|
|
m, err := NewDsmr(ctx, addr, time.Second)
|
|
require.NoError(t, err)
|
|
|
|
p, err := m.CurrentPower()
|
|
require.NoError(t, err)
|
|
require.Equal(t, -2450.0, p)
|
|
|
|
// energy via tariff fallback
|
|
me, ok := api.Cap[api.MeterEnergy](m)
|
|
require.True(t, ok, "MeterEnergy expected (tariff fallback)")
|
|
e, err := me.TotalEnergy()
|
|
require.NoError(t, err)
|
|
require.InDelta(t, 7753.468, e, 1e-6) // 1.8.1 + 1.8.2
|
|
|
|
re, ok := api.Cap[api.MeterReturnEnergy](m)
|
|
require.True(t, ok, "MeterReturnEnergy expected (tariff fallback)")
|
|
r, err := re.ReturnEnergy()
|
|
require.NoError(t, err)
|
|
require.InDelta(t, 1687.777, r, 1e-6) // 2.8.1 + 2.8.2
|
|
|
|
// L3 exports, so its current and power are negative; L1/L2 stay positive
|
|
pc, ok := api.Cap[api.PhaseCurrents](m)
|
|
require.True(t, ok, "PhaseCurrents expected with all three phases")
|
|
i1, i2, i3, err := pc.Currents()
|
|
require.NoError(t, err)
|
|
require.Equal(t, []float64{5.1, 0.0, -4.8}, []float64{i1, i2, i3})
|
|
|
|
pp, ok := api.Cap[api.PhasePowers](m)
|
|
require.True(t, ok, "PhasePowers expected with all three phases")
|
|
w1, w2, w3, err := pp.Powers()
|
|
require.NoError(t, err)
|
|
require.Equal(t, []float64{0, 0, -2450}, []float64{w1, w2, w3})
|
|
}
|
|
|
|
// TestDsmrCombinedEnergy verifies the combined 1.8.0/2.8.0 registers are
|
|
// preferred over the per-tariff sum. No real example carries both, so the
|
|
// preference is covered directly.
|
|
func TestDsmrCombinedEnergy(t *testing.T) {
|
|
m := newTestDsmr(map[string]string{
|
|
obis.EnergyImport: "1234.567", // combined; tariffs below sum to a different value
|
|
obis.EnergyImportT1: "100.000",
|
|
obis.EnergyImportT2: "200.000",
|
|
obis.EnergyExport: "89.012",
|
|
})
|
|
|
|
me, ok := api.Cap[api.MeterEnergy](m)
|
|
require.True(t, ok, "MeterEnergy expected")
|
|
e, err := me.TotalEnergy()
|
|
require.NoError(t, err)
|
|
require.InDelta(t, 1234.567, e, 1e-6)
|
|
|
|
re, ok := api.Cap[api.MeterReturnEnergy](m)
|
|
require.True(t, ok, "MeterReturnEnergy expected")
|
|
r, err := re.ReturnEnergy()
|
|
require.NoError(t, err)
|
|
require.InDelta(t, 89.012, r, 1e-6)
|
|
}
|
|
|
|
// TestDsmrPhaseGating verifies the three-phase capabilities are only offered
|
|
// when all three phases are present, not for a partial (single-phase) frame.
|
|
func TestDsmrPhaseGating(t *testing.T) {
|
|
m := newTestDsmr(map[string]string{
|
|
obis.CurrentL1: "2", // only L1 present for currents/voltages
|
|
obis.VoltageL1: "230.0",
|
|
})
|
|
|
|
_, ok := api.Cap[api.PhaseCurrents](m)
|
|
require.False(t, ok, "PhaseCurrents must not be offered with only L1")
|
|
_, ok = api.Cap[api.PhaseVoltages](m)
|
|
require.False(t, ok, "PhaseVoltages must not be offered with only L1")
|
|
_, ok = api.Cap[api.PhasePowers](m)
|
|
require.False(t, ok, "PhasePowers must not be offered without per-phase power")
|
|
}
|