Skip to content

Commit aa3c0e1

Browse files
authored
Merge pull request #29 from athombv/master
mmip
2 parents 0465d98 + 02bf38c commit aa3c0e1

8 files changed

+320
-34
lines changed

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@athombv/dsmr-parser",
3-
"version": "1.2.0",
3+
"version": "1.2.1",
44
"description": "DSMR Parser for Smart Meters",
55
"type": "module",
66
"main": "dist/index.js",

src/parsers/stream-unencrypted.ts

Lines changed: 64 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -95,38 +95,43 @@ export class UnencryptedDSMRStreamParser implements DSMRStreamParser {
9595

9696
const eofRegexResult = this.eofRegex.exec(this.telegram.toString(this.encoding));
9797

98-
// End of telegram has not been reached, wait for more data to arrive.
99-
if (!eofRegexResult) return;
100-
101-
const endOfFrameIndex = eofRegexResult.index + eofRegexResult[0].length;
102-
103-
// Clear the full frame required timeout. The full frame
104-
// has been received and the data buffer will be cleared.
105-
clearTimeout(this.fullFrameRequiredTimeout);
106-
107-
try {
108-
const result = DSMRParser({
109-
telegram: this.telegram.subarray(0, endOfFrameIndex),
110-
newLineChars: this.options.newLineChars,
111-
});
112-
113-
this.options.callback(null, result);
114-
} catch (error) {
115-
if (error instanceof DSMRError) {
116-
error.withRawTelegram(this.telegram);
98+
// End of telegram has not been reached.
99+
if (!eofRegexResult) {
100+
// Check if we've received another start of frame.
101+
// Some variants of the MT382 meters don't send an eof.
102+
// We skip the first byte, as this is already the sof of the current frame.
103+
// Note: add 1 to the index, as we skip the first byte when doing indexOf.
104+
const sofIndex = this.telegram.subarray(1).indexOf('/') + 1;
105+
106+
if (sofIndex === 0) return;
107+
108+
// Check if the characters before the sof char are newlines. Otherwise the sof
109+
// can be part of a text message element of a telegram.
110+
if (this.options.newLineChars === '\n' && sofIndex > 1) {
111+
const bytesBeforeSof = this.telegram.subarray(sofIndex - 1, sofIndex);
112+
113+
// 0x0a is a newline character.
114+
if (bytesBeforeSof[0] !== 0x0a) {
115+
return;
116+
}
117+
} else if (sofIndex > 2) {
118+
const bytesBeforeSof = this.telegram.subarray(sofIndex - 2, sofIndex);
119+
120+
// 0x0d is a carriage return and 0x0a is a newline character.
121+
if (bytesBeforeSof[0] !== 0x0d || bytesBeforeSof[1] !== 0x0a) {
122+
return;
123+
}
117124
}
118125

119-
this.options.callback(error, undefined);
126+
// Try to parse the data up to the start of the next frame.
127+
this.tryParseTelegram(sofIndex);
128+
129+
return;
120130
}
121131

122-
const remainingData = this.telegram.subarray(endOfFrameIndex, this.telegram.length);
123-
this.hasStartOfFrame = false;
124-
this.telegram = Buffer.alloc(0);
132+
const endOfFrameIndex = eofRegexResult.index + eofRegexResult[0].length;
125133

126-
// There might be more data in the buffer for the next telegram.
127-
if (remainingData.length > 0) {
128-
this.onData(remainingData);
129-
}
134+
this.tryParseTelegram(endOfFrameIndex);
130135
}
131136

132137
private checkEncryption() {
@@ -156,10 +161,39 @@ export class UnencryptedDSMRStreamParser implements DSMRStreamParser {
156161
};
157162
}
158163

164+
private tryParseTelegram(frameLength: number, overrideError?: Error) {
165+
// Clear the full frame required timeout. The full frame
166+
// has been received and the data buffer will be cleared.
167+
clearTimeout(this.fullFrameRequiredTimeout);
168+
169+
try {
170+
const result = DSMRParser({
171+
telegram: this.telegram.subarray(0, frameLength),
172+
newLineChars: this.options.newLineChars,
173+
});
174+
175+
this.options.callback(null, result);
176+
} catch (err) {
177+
const error = overrideError ?? err;
178+
if (error instanceof DSMRError) {
179+
error.withRawTelegram(this.telegram);
180+
}
181+
182+
this.options.callback(error, undefined);
183+
}
184+
185+
const remainingData = this.telegram.subarray(frameLength, this.telegram.length);
186+
this.hasStartOfFrame = false;
187+
this.telegram = Buffer.alloc(0);
188+
189+
// There might be more data in the buffer for the next telegram.
190+
if (remainingData.length > 0) {
191+
this.onData(remainingData);
192+
}
193+
}
194+
159195
private onFullFrameRequiredTimeout() {
160-
const error = new DSMRTimeoutError();
161-
error.withRawTelegram(this.telegram);
162-
this.options.callback(error, undefined);
196+
this.tryParseTelegram(this.telegram.length, new DSMRTimeoutError());
163197

164198
// Reset the entire state here, as the full frame was not received.
165199
this.clear();

tests/stream.spec.ts

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
DSMRTimeoutError,
1818
DSMRDecryptionError,
1919
DSMRParserResult,
20+
DSMRParserError,
2021
} from '../src/index.js';
2122
import { ENCRYPTED_DSMR_HEADER_LEN, ENCRYPTED_DSMR_TELEGRAM_SOF } from '../src/util/encryption.js';
2223

@@ -233,6 +234,128 @@ describe('DSMRStreamParser', () => {
233234

234235
instance.destroy();
235236
});
237+
238+
it('Parses when the CRC line is missing', async (context) => {
239+
context.mock.timers.enable();
240+
241+
const { input, output } = await readTelegramFromFiles('tests/telegrams/iskra-mt-382-no-crc');
242+
243+
const stream = new PassThrough();
244+
const callback = mock.fn();
245+
246+
const instance = DSMR.createStreamParser({
247+
stream,
248+
callback,
249+
fullFrameRequiredWithinMs: 1000,
250+
});
251+
252+
stream.write(input);
253+
254+
context.mock.timers.tick(1000);
255+
256+
stream.end();
257+
instance.destroy();
258+
259+
assert.deepStrictEqual(callback.mock.calls.length, 1);
260+
assert.deepStrictEqual(callback.mock.calls[0].arguments[0], null);
261+
assert.deepStrictEqual(callback.mock.calls[0].arguments[1], output);
262+
});
263+
264+
it('Immediately parses when CRC is missing and a 2nd telegram is received', async (context) => {
265+
context.mock.timers.enable();
266+
267+
const { input, output } = await readTelegramFromFiles(
268+
'tests/telegrams/iskra-mt-382-no-crc',
269+
true,
270+
);
271+
272+
const stream = new PassThrough();
273+
const callback = mock.fn();
274+
275+
const instance = DSMR.createStreamParser({
276+
stream,
277+
callback,
278+
fullFrameRequiredWithinMs: 1000,
279+
});
280+
281+
stream.write(input);
282+
stream.write(input);
283+
284+
assert.deepStrictEqual(callback.mock.calls.length, 1);
285+
assert.deepStrictEqual(callback.mock.calls[0].arguments[0], null);
286+
assert.deepStrictEqual(callback.mock.calls[0].arguments[1], output);
287+
288+
context.mock.timers.tick(1000);
289+
290+
stream.end();
291+
instance.destroy();
292+
293+
assert.deepStrictEqual(callback.mock.calls.length, 2);
294+
assert.deepStrictEqual(callback.mock.calls[1].arguments[0], null);
295+
assert.deepStrictEqual(callback.mock.calls[1].arguments[1], output);
296+
});
297+
298+
it('Immediately parses when CRC is missing and a three telegrams are received', async (context) => {
299+
context.mock.timers.enable();
300+
301+
const { input, output } = await readTelegramFromFiles('tests/telegrams/iskra-mt-382-no-crc');
302+
303+
const stream = new PassThrough();
304+
const callback = mock.fn();
305+
306+
const instance = DSMR.createStreamParser({
307+
stream,
308+
callback,
309+
fullFrameRequiredWithinMs: 1000,
310+
});
311+
312+
stream.write(input);
313+
stream.write(input);
314+
stream.write(input);
315+
316+
assert.deepStrictEqual(callback.mock.calls.length, 2);
317+
assert.deepStrictEqual(callback.mock.calls[0].arguments[0], null);
318+
assert.deepStrictEqual(callback.mock.calls[0].arguments[1], output);
319+
assert.deepStrictEqual(callback.mock.calls[1].arguments[0], null);
320+
assert.deepStrictEqual(callback.mock.calls[1].arguments[1], output);
321+
322+
context.mock.timers.tick(1000);
323+
324+
stream.end();
325+
instance.destroy();
326+
327+
assert.deepStrictEqual(callback.mock.calls.length, 3);
328+
assert.deepStrictEqual(callback.mock.calls[2].arguments[0], null);
329+
assert.deepStrictEqual(callback.mock.calls[2].arguments[1], output);
330+
});
331+
332+
it('Handles text messages', async (context) => {
333+
context.mock.timers.enable();
334+
335+
const { input, output } = await readTelegramFromFiles(
336+
'tests/telegrams/iskra-mt-382-no-crc-with-text-message',
337+
);
338+
339+
const stream = new PassThrough();
340+
const callback = mock.fn();
341+
342+
const instance = DSMR.createStreamParser({
343+
stream,
344+
callback,
345+
fullFrameRequiredWithinMs: 1000,
346+
});
347+
348+
stream.write(input);
349+
350+
context.mock.timers.tick(1000);
351+
352+
assert.deepStrictEqual(callback.mock.calls.length, 1);
353+
assert.deepStrictEqual(callback.mock.calls[0].arguments[0], null);
354+
assert.deepStrictEqual(callback.mock.calls[0].arguments[1], output);
355+
356+
stream.end();
357+
instance.destroy();
358+
});
236359
});
237360

238361
describe('Encrypted', () => {
@@ -404,7 +527,10 @@ describe('DSMRStreamParser', () => {
404527
// it should be able to detect that it is an encrypted frame.
405528
for (let index = 1; index < callback.mock.calls.length; index++) {
406529
const error = callback.mock.calls[index].arguments[0] as unknown;
407-
assert.ok(error instanceof DSMRDecodeError && !(error instanceof DSMRDecryptionRequired));
530+
assert.ok(
531+
(error instanceof DSMRDecodeError && !(error instanceof DSMRDecryptionRequired)) ||
532+
error instanceof DSMRParserError,
533+
);
408534
assert.deepStrictEqual(callback.mock.calls[index].arguments[1], undefined);
409535
}
410536
});
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
{
2+
"raw": "/ISk5\\2MT382-1003\r\n\r\n0-0:96.1.1(00112233445566778899aabbccddeeff)\r\n1-0:1.8.1(39837.604*kWh)\r\n1-0:1.8.2(30477.225*kWh)\r\n1-0:2.8.1(05174.479*kWh)\r\n1-0:2.8.2(11772.946*kWh)\r\n0-0:96.14.0(0002)\r\n1-0:1.7.0(0000.00*kW)\r\n1-0:2.7.0(0000.14*kW)\r\n0-0:17.0.0(0999.00*kW)\r\n0-0:96.3.10(1)\r\n0-0:96.13.1()\r\n0-0:96.13.0(test-/-test)\r\n0-1:24.1.0(3)\r\n0-1:96.1.0(0011223344556677889900112233445566)\r\n0-1:24.3.0(250423090000)(00)(60)(1)(0-1:24.2.1)(m3)\r\n(13032.850)\r\n0-1:24.4.0(1)\r\n",
3+
"header": {
4+
"identifier": "\\2MT382-1003",
5+
"xxx": "ISk",
6+
"z": "5"
7+
},
8+
"metadata": {
9+
"equipmentId": "00112233445566778899aabbccddeeff",
10+
"unknownLines": [
11+
"0-0:17.0.0(0999.00*kW)",
12+
"0-0:96.3.10(1)",
13+
"(13032.850)",
14+
"0-1:24.4.0(1)"
15+
],
16+
"numericMessage": 0,
17+
"textMessage": "test-/-test"
18+
},
19+
"electricity": {
20+
"tariffs": {
21+
"1": {
22+
"received": 39837.604,
23+
"returned": 5174.479
24+
},
25+
"2": {
26+
"received": 30477.225,
27+
"returned": 11772.946
28+
}
29+
},
30+
"currentTariff": 2,
31+
"powerReceivedTotal": 0,
32+
"powerReturnedTotal": 0.14
33+
},
34+
"mBus": {
35+
"1": {
36+
"deviceType": 3,
37+
"equipmentId": "0011223344556677889900112233445566",
38+
"timestamp": "250423090000",
39+
"value": 13032.85,
40+
"unit": "m3",
41+
"recordingPeriodMinutes": 60
42+
}
43+
}
44+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/ISk5\2MT382-1003
2+
3+
0-0:96.1.1(00112233445566778899aabbccddeeff)
4+
1-0:1.8.1(39837.604*kWh)
5+
1-0:1.8.2(30477.225*kWh)
6+
1-0:2.8.1(05174.479*kWh)
7+
1-0:2.8.2(11772.946*kWh)
8+
0-0:96.14.0(0002)
9+
1-0:1.7.0(0000.00*kW)
10+
1-0:2.7.0(0000.14*kW)
11+
0-0:17.0.0(0999.00*kW)
12+
0-0:96.3.10(1)
13+
0-0:96.13.1()
14+
0-0:96.13.0(test-/-test)
15+
0-1:24.1.0(3)
16+
0-1:96.1.0(0011223344556677889900112233445566)
17+
0-1:24.3.0(250423090000)(00)(60)(1)(0-1:24.2.1)(m3)
18+
(13032.850)
19+
0-1:24.4.0(1)
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
{
2+
"raw": "/ISk5\\2MT382-1003\r\n\r\n0-0:96.1.1(00112233445566778899aabbccddeeff)\r\n1-0:1.8.1(39837.604*kWh)\r\n1-0:1.8.2(30477.225*kWh)\r\n1-0:2.8.1(05174.479*kWh)\r\n1-0:2.8.2(11772.946*kWh)\r\n0-0:96.14.0(0002)\r\n1-0:1.7.0(0000.00*kW)\r\n1-0:2.7.0(0000.14*kW)\r\n0-0:17.0.0(0999.00*kW)\r\n0-0:96.3.10(1)\r\n0-0:96.13.1()\r\n0-0:96.13.0()\r\n0-1:24.1.0(3)\r\n0-1:96.1.0(0011223344556677889900112233445566)\r\n0-1:24.3.0(250423090000)(00)(60)(1)(0-1:24.2.1)(m3)\r\n(13032.850)\r\n0-1:24.4.0(1)\r\n",
3+
"header": {
4+
"identifier": "\\2MT382-1003",
5+
"xxx": "ISk",
6+
"z": "5"
7+
},
8+
"metadata": {
9+
"equipmentId": "00112233445566778899aabbccddeeff",
10+
"unknownLines": [
11+
"0-0:17.0.0(0999.00*kW)",
12+
"0-0:96.3.10(1)",
13+
"(13032.850)",
14+
"0-1:24.4.0(1)"
15+
],
16+
"numericMessage": 0,
17+
"textMessage": ""
18+
},
19+
"electricity": {
20+
"tariffs": {
21+
"1": {
22+
"received": 39837.604,
23+
"returned": 5174.479
24+
},
25+
"2": {
26+
"received": 30477.225,
27+
"returned": 11772.946
28+
}
29+
},
30+
"currentTariff": 2,
31+
"powerReceivedTotal": 0,
32+
"powerReturnedTotal": 0.14
33+
},
34+
"mBus": {
35+
"1": {
36+
"deviceType": 3,
37+
"equipmentId": "0011223344556677889900112233445566",
38+
"timestamp": "250423090000",
39+
"value": 13032.85,
40+
"unit": "m3",
41+
"recordingPeriodMinutes": 60
42+
}
43+
}
44+
}

0 commit comments

Comments
 (0)