]> git.basschouten.com Git - openhab-addons.git/blob
e7c17071c96deb13c4ceeef92dfb518935b59b47
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
7  * This program and the accompanying materials are made available under the
8  * terms of the Eclipse Public License 2.0 which is available at
9  * http://www.eclipse.org/legal/epl-2.0
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.dsmr.internal.device.p1telegram;
14
15 import java.nio.charset.StandardCharsets;
16 import java.util.AbstractMap.SimpleEntry;
17 import java.util.ArrayList;
18 import java.util.Collections;
19 import java.util.List;
20 import java.util.Map.Entry;
21 import java.util.Optional;
22 import java.util.regex.Pattern;
23
24 import org.eclipse.jdt.annotation.NonNullByDefault;
25 import org.openhab.binding.dsmr.internal.device.connector.DSMRErrorStatus;
26 import org.openhab.binding.dsmr.internal.device.cosem.CosemObject;
27 import org.openhab.binding.dsmr.internal.device.cosem.CosemObjectFactory;
28 import org.slf4j.Logger;
29 import org.slf4j.LoggerFactory;
30
31 /**
32  * The {@link P1TelegramParser} is a class that will read P1-port data create a full P1
33  * telegram.
34  *
35  * Data can be parsed in chunks. If a full P1 telegram is received, listeners are notified
36  *
37  * @author M. Volaart - Initial contribution
38  * @author Hilbrand Bouwkamp - Removed asynchronous call and some clean up
39  */
40 @NonNullByDefault
41 public class P1TelegramParser implements TelegramParser {
42
43     /**
44      * State of the parser
45      */
46     private enum State {
47         /** Wait for the '/' character */
48         WAIT_FOR_START,
49         /** '/' character seen */
50         HEADER,
51         /** Waiting for the header to end with a CR & LF */
52         CRLF,
53         /** Handling OBIS Identifier */
54         DATA_OBIS_ID,
55         /** Parsing OBIS value */
56         DATA_OBIS_VALUE,
57         /** OBIS value end seen ')' */
58         DATA_OBIS_VALUE_END,
59         /** Parsing CRC value following '!' */
60         CRC_VALUE
61     }
62
63     /**
64      * Pattern for the CRC-code
65      */
66     private static final String CRC_PATTERN = "[0-9A-Z]{4}";
67
68     private final Logger logger = LoggerFactory.getLogger(P1TelegramParser.class);
69
70     /* internal state variables */
71
72     /**
73      * current obisId buffer.
74      */
75     private final StringBuilder obisId = new StringBuilder();
76
77     /**
78      * Current cosem object values buffer.
79      */
80     private final StringBuilder obisValue = new StringBuilder();
81
82     /**
83      * In lenient mode store raw data and log when a complete message is received.
84      */
85     private final StringBuilder rawData = new StringBuilder();
86
87     /**
88      * Current crc value read.
89      */
90     private final StringBuilder crcValue = new StringBuilder();
91
92     /**
93      * CRC calculation helper
94      */
95     private final CRC16 crc;
96
97     /**
98      * Current state of the P1 telegram parser
99      */
100     private volatile State state = State.WAIT_FOR_START;
101
102     /**
103      * Work in lenient mode (more fault tolerant)
104      */
105     private volatile boolean lenientMode;
106
107     /**
108      * Current telegram state
109      */
110     private volatile Optional<DSMRErrorStatus> telegramState = Optional.empty();
111
112     /**
113      * CosemObjectFactory helper class
114      */
115     private final CosemObjectFactory factory;
116
117     /**
118      * Received Cosem Objects in the P1Telegram that is currently received
119      */
120     private final List<Entry<String, String>> cosemObjects = new ArrayList<>();
121
122     /**
123      * List of Cosem Object values that are not known to this binding.
124      */
125     private final List<Entry<String, String>> unknownCosemObjects = new ArrayList<>();
126
127     /**
128      * Listener for new P1 telegrams
129      */
130     private final P1TelegramListener telegramListener;
131
132     /**
133      * Enable in tests. Will throw an exception on CRC error.
134      */
135     private final boolean test;
136
137     /**
138      * Creates a new P1TelegramParser
139      *
140      * @param telegramListener
141      */
142     public P1TelegramParser(final P1TelegramListener telegramListener) {
143         this(telegramListener, false);
144     }
145
146     public P1TelegramParser(final P1TelegramListener telegramListener, final boolean test) {
147         this.telegramListener = telegramListener;
148         this.test = test;
149
150         factory = new CosemObjectFactory();
151         state = State.WAIT_FOR_START;
152         crc = new CRC16(CRC16.Polynom.CRC16_IBM);
153         telegramState = Optional.empty();
154     }
155
156     /**
157      * Parses data. If a complete message is received the message will be passed to the telegramListener.
158      *
159      * @param data byte data to parse
160      * @param length number of bytes to parse
161      */
162     @Override
163     public void parse(final byte[] data, final int length) {
164         if (lenientMode || logger.isTraceEnabled()) {
165             final String rawBlock = new String(data, 0, length, StandardCharsets.UTF_8);
166
167             if (lenientMode) {
168                 rawData.append(rawBlock);
169             }
170             if (logger.isTraceEnabled()) {
171                 logger.trace("Raw data: {}, Parser state entering parseData: {}", rawBlock, state);
172             }
173         }
174         for (int i = 0; i < length; i++) {
175             final char c = (char) data[i];
176
177             switch (state) {
178                 case WAIT_FOR_START:
179                     if (c == '/') {
180                         setState(State.HEADER);
181                     }
182                     break;
183                 case HEADER:
184                     if (c == '\r') {
185                         setState(State.CRLF);
186                     }
187                     break;
188                 case CRLF:
189                     if (Character.isWhitespace(c)) { // NOPMD EmptyIfStmt
190                         // do nothing
191                     } else if (Character.isDigit(c)) {
192                         setState(State.DATA_OBIS_ID);
193                     } else {
194                         handleUnexpectedCharacter(c);
195
196                         setState(State.WAIT_FOR_START);
197                     }
198                     break;
199                 case DATA_OBIS_ID:
200                     if (Character.isWhitespace(c)) { // NOPMD EmptyIfStmt
201                         // ignore
202                     } else if (Character.isDigit(c) || c == ':' || c == '-' || c == '.' || c == '*') { // NOPMD
203                         // do nothing
204                     } else if (c == '(') {
205                         setState(State.DATA_OBIS_VALUE);
206                     } else if (c == '!') {
207                         handleUnexpectedCharacter(c);
208                         if (lenientMode) {
209                             // Clear current Obis Data (Keep already received data)
210                             clearObisData();
211                             setState(State.CRC_VALUE);
212                         } else {
213                             setState(State.WAIT_FOR_START);
214                         }
215                     } else {
216                         handleUnexpectedCharacter(c);
217
218                         if (lenientMode) {
219                             clearObisData();
220                             setState(State.DATA_OBIS_ID);
221                         } else {
222                             setState(State.WAIT_FOR_START);
223                         }
224                     }
225                     break;
226                 case DATA_OBIS_VALUE:
227                     if (c == ')') {
228                         setState(State.DATA_OBIS_VALUE_END);
229                     }
230                     break;
231                 case DATA_OBIS_VALUE_END:
232                     if (Character.isWhitespace(c)) { // NOPMD EmptyIfStmt
233                         // ignore
234                     } else if (Character.isDigit(c)) {
235                         setState(State.DATA_OBIS_ID);
236                     } else if (c == '(') {
237                         setState(State.DATA_OBIS_VALUE);
238                     } else if (c == '!') {
239                         setState(State.CRC_VALUE);
240                     } else {
241                         handleUnexpectedCharacter(c);
242
243                         if (!lenientMode) {
244                             setState(State.WAIT_FOR_START);
245                         } // Other wise try to recover in lenient mode
246                     }
247                     break;
248
249                 case CRC_VALUE:
250                     /*
251                      * Normally the P1 telegram ends with a \r\n sequence
252                      * If we already see a '/' character we also assume the current
253                      * P1 telegram is correctly finished
254                      */
255                     if (c == '\r' || c == '/') {
256                         logger.trace("telegramState {}, crcValue to check 0x{}", telegramState, crcValue);
257                         // Only perform CRC check if telegram is still ok
258
259                         if (telegramState.isEmpty() && crcValue.length() > 0) {
260                             telegramState = checkCRC();
261                         }
262                         processTelegram();
263                         reset();
264                         if (c == '/') {
265                             /*
266                              * Immediately proceed to the next state (robust implementation for meter that do not follow
267                              * the specification
268                              */
269                             setState(State.HEADER);
270                         }
271                     }
272                     break;
273             }
274
275             handleCharacter(c);
276         }
277         logger.trace("State after parsing: {}", state);
278     }
279
280     private Optional<DSMRErrorStatus> checkCRC() {
281         final Optional<DSMRErrorStatus> telegramState;
282
283         if (Pattern.matches(CRC_PATTERN, crcValue)) {
284             final int crcP1Telegram = Integer.parseInt(crcValue.toString(), 16);
285             final int calculatedCRC = crc.getCurrentCRCCode();
286
287             if (logger.isDebugEnabled()) {
288                 logger.trace("received CRC value: {}, calculated CRC value: 0x{}", crcValue,
289                         String.format("%04X", calculatedCRC));
290             }
291             if (crcP1Telegram != calculatedCRC) {
292                 if (test) {
293                     throw new IllegalArgumentException(
294                             String.format("Invalid CRC. Read: %s, expected: %04X", crcValue, calculatedCRC));
295                 }
296                 logger.trace("CRC value does not match, p1 Telegram failed");
297
298                 telegramState = Optional.of(DSMRErrorStatus.TELEGRAM_CRC_ERROR);
299             } else {
300                 telegramState = Optional.empty();
301             }
302         } else {
303             telegramState = Optional.of(DSMRErrorStatus.TELEGRAM_CRC_ERROR);
304         }
305         return telegramState;
306     }
307
308     private void processTelegram() {
309         telegramState.ifPresentOrElse(error -> telegramListener.onError(error, ""),
310                 () -> telegramListener.telegramReceived(constructTelegram()));
311     }
312
313     private P1Telegram constructTelegram() {
314         final List<CosemObject> cosemObjectsCopy = new ArrayList<>();
315
316         cosemObjects.stream().forEach(e -> addCosemObject(cosemObjectsCopy, e));
317         if (lenientMode) {
318             return new P1Telegram(cosemObjectsCopy, rawData.toString(),
319                     unknownCosemObjects.isEmpty() ? Collections.emptyList() : new ArrayList<>(unknownCosemObjects));
320         } else {
321             return new P1Telegram(cosemObjectsCopy);
322         }
323     }
324
325     private void addCosemObject(final List<CosemObject> objects, final Entry<String, String> cosemEntry) {
326         final String obisIdString = cosemEntry.getKey();
327         final String obisValueString = cosemEntry.getValue();
328         final CosemObject cosemObject = factory.getCosemObject(obisIdString, obisValueString);
329
330         if (cosemObject == null) {
331             if (lenientMode) {
332                 unknownCosemObjects.add(new SimpleEntry<>(obisIdString, obisValueString));
333             }
334         } else {
335             logger.trace("Adding {} to list of Cosem Objects", cosemObject);
336             objects.add(cosemObject);
337         }
338     }
339
340     @Override
341     public void reset() {
342         setState(State.WAIT_FOR_START);
343     }
344
345     /**
346      * Handles an unexpected character. The character will be logged and the current telegram is marked corrupted
347      *
348      * @param c the unexpected character
349      */
350     private void handleUnexpectedCharacter(final char c) {
351         logger.debug("Unexpected character '{}' in state: {}. This P1 telegram is marked as failed", c, state);
352
353         telegramState = Optional.of(DSMRErrorStatus.TELEGRAM_DATA_CORRUPTION);
354         telegramListener.onError(DSMRErrorStatus.TELEGRAM_DATA_CORRUPTION, "");
355     }
356
357     /**
358      * Stores a single character
359      *
360      * @param c the character to process
361      */
362     private void handleCharacter(final char c) {
363         switch (state) {
364             case WAIT_FOR_START:
365                 // ignore the data
366                 break;
367             case HEADER:
368                 crc.processByte((byte) c);
369                 break;
370             case CRLF:
371                 crc.processByte((byte) c);
372                 break;
373             case DATA_OBIS_ID:
374                 obisId.append(c);
375                 crc.processByte((byte) c);
376                 break;
377             case DATA_OBIS_VALUE:
378                 obisValue.append(c);
379                 crc.processByte((byte) c);
380                 break;
381             case DATA_OBIS_VALUE_END:
382                 obisValue.append(c);
383                 crc.processByte((byte) c);
384                 break;
385             case CRC_VALUE:
386                 if (c == '!') {
387                     crc.processByte((byte) c);
388                 } else {
389                     crcValue.append(c);
390                 }
391                 // CRC data is not part of received data
392                 break;
393             default:
394                 break;
395         }
396     }
397
398     /**
399      * Clears all internal state
400      */
401     private void clearInternalData() {
402         obisId.setLength(0);
403         obisValue.setLength(0);
404         rawData.setLength(0);
405         crcValue.setLength(0);
406         crc.initialize();
407         cosemObjects.clear();
408         unknownCosemObjects.clear();
409     }
410
411     /**
412      * Clears all the current OBIS data. I.e.
413      * - current OBIS identifier
414      * - current OBIS value
415      */
416     private void clearObisData() {
417         obisId.setLength(0);
418         obisValue.setLength(0);
419     }
420
421     /**
422      * Store the current CosemObject in the list of received cosem Objects
423      */
424     private void storeCurrentCosemObject() {
425         final String obisIdString = obisId.toString();
426
427         if (!obisIdString.isEmpty()) {
428             cosemObjects.add(new SimpleEntry<String, String>(obisIdString, obisValue.toString()));
429         }
430         clearObisData();
431     }
432
433     /**
434      * @param newState the new state to set
435      */
436     private void setState(final State newState) {
437         synchronized (state) {
438             switch (newState) {
439                 case HEADER:
440                     // Clear CRC data and mark current telegram as OK
441                     crc.initialize();
442                     break;
443                 case WAIT_FOR_START:
444                     // Clears internal state data and mark current telegram as OK
445                     clearInternalData();
446                     telegramState = Optional.empty();
447                     break;
448                 case DATA_OBIS_ID:
449                     // If the current state is CRLF we are processing the header and don't have a cosem object yet
450                     if (state != State.CRLF) {
451                         storeCurrentCosemObject();
452                     }
453                     break;
454                 case CRC_VALUE:
455                     storeCurrentCosemObject();
456                     break;
457                 default:
458                     break;
459             }
460             state = newState;
461         }
462     }
463
464     @Override
465     public void setLenientMode(final boolean lenientMode) {
466         this.lenientMode = lenientMode;
467     }
468 }