-
Notifications
You must be signed in to change notification settings - Fork 2
/
dsmr4p1.go
202 lines (179 loc) · 5.88 KB
/
dsmr4p1.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
// Package dsmr4p1 is a library for reading (and parsing) data from the P1 port of dutch smart meters.
package dsmr4p1
import (
"bufio"
"bytes"
"errors"
"fmt"
"io"
"log"
"strconv"
"strings"
"time"
"github.com/howeyc/crc16"
)
// Constants for now as I'm assuming all dutch smartmeters will be in the
// same Dutch timezone.
const (
summerTimezone = "CEST"
winterTimezone = "CET"
)
var (
// ErrorParseTimestamp indicates that there was an error parsing a timestamp.
ErrorParseTimestamp = errors.New("error parsing timestamp: missing DST indicator")
// ErrorParseValueWithUnit indicates that there was an error parsing a value string
// (i.e., a string containing both a value and a unit)
ErrorParseValueWithUnit = errors.New("error parsing string that should contain both a value and a unit")
// According to the DSMR 4.0.4 spec, the CRC16 here uses the polynomial
// x^16 + x^15 + x^2 + 1, which is the same polynomial as in CRC16-IBM.
// However, we cannot simply use the IBM checksum of the crc16 package as this version
// of the spec (as opposed to the 4.0 version) also states: "CRC16 uses no XOR in,
// no XOR out and is computed with least significant bit first."
// Hence, we define our own table here.
ibmTableNoXOR = crc16.MakeTableNoXOR(crc16.IBM)
)
// ParseTimestamp parses the timestamp format used in the dutch smartmeters. Do
// note this function assumes the CET/CEST timezone.
func ParseTimestamp(timestamp string) (time.Time, error) {
// The format for the timestamp is:
// YYMMDDhhmmssX
// The value used for X determines whether DST is active.
// S (summer?) means yes, W (winter?) means no.
var timezone string
switch timestamp[len(timestamp)-1] {
case 'S':
timezone = summerTimezone
case 'W':
timezone = winterTimezone
default:
return time.Time{}, ErrorParseTimestamp
}
// To make sure parsing is always consistent and indepentent of the the local
// timezone of the host this code is running on, let's for now assume Dutch
// time.
loc, err := time.LoadLocation("Europe/Amsterdam")
timestamp = timestamp[:len(timestamp)-1] + " " + timezone
ts, err := time.ParseInLocation("060102150405 MST", timestamp, loc)
if err != nil {
return ts, err
}
return ts, nil
}
// ParseValueWithUnit parses the provided string into a float and a unit. If the
// unit starts with "k" the value is multiplied by 1000 and the "k" is removed
// from the unit.
func ParseValueWithUnit(input string) (value float64, unit string, err error) {
parts := strings.Split(input, "*")
if len(parts) != 2 {
err = ErrorParseValueWithUnit
return
}
value, err = strconv.ParseFloat(parts[0], 64)
if err != nil {
return
}
unit = parts[1]
if strings.HasPrefix(unit, "k") {
value *= 1000
unit = unit[1:]
}
return
}
// Starts polling and attempts to parse a telegram.
func startPolling(input io.Reader, ch chan Telegram) {
br := bufio.NewReader(input)
for {
// Read until we find a '/', which should be the beginning of the telegram.
_, err := br.ReadBytes('/')
if err == io.EOF {
break
} else if err != nil {
log.Println(err)
continue
}
// Unread the byte as the '/' is also part of the CRC computation.
err = br.UnreadByte()
if err != nil {
log.Println(err)
continue
}
// The '!' character signals the end of the telegram.
data, err := br.ReadBytes('!')
if err != nil {
log.Println(err)
continue
}
// The four hexadecimal characters are the CRC-16 of the preceding data, delimitted by
// a carriage return.
crcBytes, err := br.ReadBytes('\n')
if err != nil {
log.Println(err)
continue
}
if len(crcBytes) != 6 {
log.Println("Unexpected number of CRC bytes.")
continue // Maybe we can recover?
}
dataCRC := string(crcBytes[:4])
computedCRC := fmt.Sprintf("%04X", crc16.Checksum(data, ibmTableNoXOR))
if dataCRC == computedCRC {
t := Telegram(data)
ch <- t
} else {
log.Printf("CRC values do not match: %s vs %s\n", dataCRC, computedCRC)
}
}
// Close the channel (should only happen with EOF, allows for clean exit).
close(ch)
}
// Poll starts polling the P1 port represented by input (an io.Reader). It will
// start a goroutine and received telegrams are put into returned channel. Only
// telegrams whose CRC value are correct are put into the channel.
func Poll(input io.Reader) chan Telegram {
ch := make(chan Telegram)
go startPolling(input, ch)
return ch
}
// Some code to simulate a smartmeter
type delayedReader struct {
rd *bufio.Reader
delim byte
ticker *time.Ticker
}
func (dr *delayedReader) Read(p []byte) (n int, err error) {
tmp, _ := dr.rd.Peek(len(p))
i1 := bytes.IndexByte(tmp, dr.delim)
// No start of telegram here, just continue reading
if i1 == -1 {
n, err = dr.rd.Read(p)
return
}
// So there is a '/' coming up. If the '/' is not the first charactar, simply
// let read until it is.
if i1 != 0 {
n, err = dr.rd.Read(p[:i1])
return
}
// i1 == 0, so tmp[0] == '/': a new telegram is coming up. Let's wait until
// the ticker fires.
<-dr.ticker.C
// Ok, but how much should we return? Is there maybe another '/'?
i2 := bytes.IndexByte(tmp[i1+1:], dr.delim)
// If there isn't, just read the rest.
if i2 == -1 {
n, err = dr.rd.Read(p)
return
}
// Finally, if there is another '/' coming up, read until that character.
n, err = dr.rd.Read(p[:i2])
return
}
// RateLimit takes a io.Reader (typically the output of a os.Open) and delay the
// output of each Telegram (delimited by a '/') at a certain rate (delay). The
// main purpose is for testing/simulation. Simply save the output of an actual
// smartmeter to a file. Then in your test program open the file and use the
// resulting io.Reader with this function. The resulting io.Reader will mimick a
// real smart-meter that outputs a telegram every n seconds (typically 10).
func RateLimit(input io.Reader, delay time.Duration) io.Reader {
return &delayedReader{rd: bufio.NewReader(input), delim: '/', ticker: time.NewTicker(delay)}
}