Skip to content

Commit 9cebe94

Browse files
committed
fix: make end of telegram char optional
Some ISK MT382 meters don't send the last line of the DSMR telegram (which is "!\r\n"). I've added support for this, it is not ideal as we just try to parse the telegram after the timeout was reached, or when a new start of frame was received.
1 parent b346031 commit 9cebe94

File tree

6 files changed

+317
-31
lines changed

6 files changed

+317
-31
lines changed

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+
}
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()
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)

0 commit comments

Comments
 (0)