]> git.basschouten.com Git - openhab-addons.git/blob
757d48ec6d5f87773b2d6dae9e03ae045ace3f8d
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2024 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.knx.internal.dpt;
14
15 import static org.openhab.binding.knx.internal.KNXBindingConstants.disableUoM;
16
17 import java.math.BigDecimal;
18 import java.text.ParseException;
19 import java.text.SimpleDateFormat;
20 import java.util.Calendar;
21 import java.util.Date;
22 import java.util.Locale;
23 import java.util.Set;
24 import java.util.regex.Matcher;
25 import java.util.regex.Pattern;
26
27 import org.eclipse.jdt.annotation.NonNullByDefault;
28 import org.eclipse.jdt.annotation.Nullable;
29 import org.openhab.core.library.types.DateTimeType;
30 import org.openhab.core.library.types.DecimalType;
31 import org.openhab.core.library.types.HSBType;
32 import org.openhab.core.library.types.IncreaseDecreaseType;
33 import org.openhab.core.library.types.OnOffType;
34 import org.openhab.core.library.types.OpenClosedType;
35 import org.openhab.core.library.types.PercentType;
36 import org.openhab.core.library.types.QuantityType;
37 import org.openhab.core.library.types.StopMoveType;
38 import org.openhab.core.library.types.StringType;
39 import org.openhab.core.library.types.UpDownType;
40 import org.openhab.core.types.Type;
41 import org.openhab.core.types.UnDefType;
42 import org.openhab.core.util.ColorUtil;
43 import org.slf4j.Logger;
44 import org.slf4j.LoggerFactory;
45
46 import tuwien.auto.calimero.KNXException;
47 import tuwien.auto.calimero.KNXFormatException;
48 import tuwien.auto.calimero.KNXIllegalArgumentException;
49 import tuwien.auto.calimero.dptxlator.DPTXlator;
50 import tuwien.auto.calimero.dptxlator.DPTXlator1BitControlled;
51 import tuwien.auto.calimero.dptxlator.DPTXlator2ByteUnsigned;
52 import tuwien.auto.calimero.dptxlator.DPTXlator3BitControlled;
53 import tuwien.auto.calimero.dptxlator.DPTXlator64BitSigned;
54 import tuwien.auto.calimero.dptxlator.DPTXlator8BitUnsigned;
55 import tuwien.auto.calimero.dptxlator.DPTXlatorBoolean;
56 import tuwien.auto.calimero.dptxlator.DPTXlatorDateTime;
57 import tuwien.auto.calimero.dptxlator.DPTXlatorSceneControl;
58 import tuwien.auto.calimero.dptxlator.TranslatorTypes;
59
60 /**
61  * This class decodes raw data received from the KNX bus to an openHAB datatype
62  *
63  * Parts of this code are based on the openHAB KNXCoreTypeMapper by Kai Kreuzer et al.
64  *
65  * @author Jan N. Klug - Initial contribution
66  */
67 @NonNullByDefault
68 public class ValueDecoder {
69     private static final Logger LOGGER = LoggerFactory.getLogger(ValueDecoder.class);
70
71     private static final String TIME_DAY_FORMAT = "EEE, HH:mm:ss";
72     private static final String TIME_FORMAT = "HH:mm:ss";
73     private static final String DATE_FORMAT = "yyyy-MM-dd";
74     // RGB: "r:123 g:123 b:123" value-range: 0-255
75     private static final Pattern RGB_PATTERN = Pattern.compile("r:(?<r>\\d+) g:(?<g>\\d+) b:(?<b>\\d+)");
76     // RGBW: "100 27 25 12 %", value range: 0-100, invalid values: "-"
77     private static final Pattern RGBW_PATTERN = Pattern
78             .compile("(?:(?<r>[\\d,.]+)|-)\\s(?:(?<g>[\\d,.]+)|-)\\s(?:(?<b>[\\d,.]+)|-)\\s(?:(?<w>[\\d,.]+)|-)\\s%");
79     // xyY: "(0,123 0,123) 56 %", value range 0-1 for xy (comma or point as decimal point), 0-100 for Y, invalid values
80     // omitted
81     public static final Pattern XYY_PATTERN = Pattern
82             .compile("(?:\\((?<x>\\d+(?:[,.]\\d+)?) (?<y>\\d+(?:[,.]\\d+)?)\\))?\\s*(?:(?<Y>\\d+(?:[,.]\\d+)?)\\s%)?");
83     public static final Pattern TSD_SEPARATOR = Pattern.compile("^[0-9](?<sep>[,\\.])[0-9][0-9][0-9].*");
84
85     private static boolean check235001(byte[] data) throws KNXException {
86         if (data.length != 6) {
87             throw new KNXFormatException("DPT235 broken frame");
88         }
89         if ((data[5] & 2) == 0) {
90             LOGGER.trace("DPT235.001 w/o ActiveEnergy ignored");
91             return false;
92         }
93         return true;
94     }
95
96     private static boolean check23561001(byte[] data) throws KNXException {
97         if (data.length != 6) {
98             throw new KNXFormatException("DPT235 broken frame");
99         }
100         if ((data[5] & 1) == 0) {
101             LOGGER.trace("DPT235.61001 w/o Tariff ignored");
102             return false;
103         }
104         return true;
105     }
106
107     /**
108      * convert the raw value received to the corresponding openHAB value
109      *
110      * @param dptId the DPT of the given data
111      * @param data a byte array containing the value
112      * @param preferredType the preferred datatype for this conversion
113      * @return the data converted to an openHAB Type (or null if conversion failed)
114      */
115     public static @Nullable Type decode(String dptId, byte[] data, Class<? extends Type> preferredType) {
116         try {
117             String value = "";
118             String translatorDptId = dptId;
119             DPTXlator translator;
120             try {
121                 translator = TranslatorTypes.createTranslator(0, DPTUtil.NORMALIZED_DPT.getOrDefault(dptId, dptId));
122                 translator.setData(data);
123                 value = translator.getValue();
124                 translatorDptId = translator.getType().getID();
125             } catch (KNXException e) {
126                 // special handling for decoding DPTs not yet supported by Calimero
127                 if ("235.001".equals(dptId)) {
128                     if (!check235001(data)) {
129                         return null;
130                     }
131                     translator = TranslatorTypes.createTranslator(0, "13.010");
132                     translator.setData(data);
133                     value = translator.getValue();
134                     dptId = "13.010";
135                     translatorDptId = dptId;
136                 } else if ("235.61001".equals(dptId)) {
137                     if (!check23561001(data)) {
138                         return null;
139                     }
140                     translator = TranslatorTypes.createTranslator(0, "5.006");
141                     translator.setData(new byte[] { data[4] });
142                     value = translator.getValue();
143                     dptId = "5.006";
144                     translatorDptId = dptId;
145                 } else {
146                     // no known special case, handle unknown translator outer try block
147                     throw e;
148                 }
149             }
150             String id = dptId; // prefer using the user-supplied DPT
151
152             Matcher m = DPTUtil.DPT_PATTERN.matcher(id);
153             if (!m.matches() || m.groupCount() != 2) {
154                 LOGGER.trace("User-Supplied DPT '{}' did not match for sub-type, using DPT returned from Translator",
155                         id);
156                 id = translatorDptId;
157                 m = DPTUtil.DPT_PATTERN.matcher(id);
158                 if (!m.matches() || m.groupCount() != 2) {
159                     LOGGER.warn("Couldn't identify main/sub number in dptID '{}'", id);
160                     return null;
161                 }
162             }
163             LOGGER.trace("Finally using datapoint DPT = {}", id);
164
165             String mainType = m.group("main");
166             String subType = m.group("sub");
167
168             switch (mainType) {
169                 case "1":
170                     return handleDpt1(subType, translator, preferredType);
171                 case "2":
172                     DPTXlator1BitControlled translator1BitControlled = (DPTXlator1BitControlled) translator;
173                     int decValue = (translator1BitControlled.getControlBit() ? 2 : 0)
174                             + (translator1BitControlled.getValueBit() ? 1 : 0);
175                     return new DecimalType(decValue);
176                 case "3":
177                     return handleDpt3(subType, translator);
178                 case "6":
179                     if ("020".equals(subType)) {
180                         return handleStringOrDecimal(data, value, preferredType, 8);
181                     } else {
182                         return handleNumericDpt(id, translator, preferredType);
183                     }
184                 case "10":
185                     return handleDpt10(value);
186                 case "11":
187                     return DateTimeType.valueOf(new SimpleDateFormat(DateTimeType.DATE_PATTERN)
188                             .format(new SimpleDateFormat(DATE_FORMAT).parse(value)));
189                 case "18":
190                     DPTXlatorSceneControl translatorSceneControl = (DPTXlatorSceneControl) translator;
191                     int decimalValue = translatorSceneControl.getSceneNumber();
192                     if (value.startsWith("learn")) {
193                         decimalValue += 0x80;
194                     }
195                     return new DecimalType(decimalValue);
196                 case "19":
197                     return handleDpt19(translator, data);
198                 case "20":
199                 case "21":
200                     return handleStringOrDecimal(data, value, preferredType, 8);
201                 case "22":
202                     return handleStringOrDecimal(data, value, preferredType, 16);
203                 case "16":
204                 case "28":
205                 case "250": // Map all combined color transitions to String,
206                 case "252": // as no native support is planned.
207                 case "253": // Currently only one subtype 2xx.600
208                 case "254": // is defined for those DPTs.
209                     return StringType.valueOf(value);
210                 case "243": // color translation, fix regional
211                 case "249": // settings
212                     // workaround for different number formats, this is to fix time>=1000s:
213                     // time is last block and may contain . and ,
214                     int sep = java.lang.Math.max(value.indexOf(" % "), value.indexOf(" K "));
215                     String time = value.substring(sep + 3);
216                     Matcher mt = TSD_SEPARATOR.matcher(time);
217                     if (mt.matches()) {
218                         int dp = time.indexOf(mt.group("sep"));
219                         value = value.substring(0, sep + dp + 3) + time.substring(dp + 1);
220                     }
221                     return StringType.valueOf(value.replace(',', '.').replace(". ", ", "));
222                 case "232":
223                     return handleDpt232(value, subType);
224                 case "242":
225                     return handleDpt242(value);
226                 case "251":
227                     return handleDpt251(value, subType, preferredType);
228                 default:
229                     return handleNumericDpt(id, translator, preferredType);
230             }
231         } catch (NumberFormatException | KNXFormatException | KNXIllegalArgumentException | ParseException e) {
232             LOGGER.info("Translator couldn't parse data '{}' for datapoint type '{}' ({}).", data, dptId, e.getClass());
233         } catch (KNXException e) {
234             // should never happen unless Calimero changes
235             LOGGER.warn("Failed creating a translator for datapoint type '{}'. Please open an issue.", dptId, e);
236         }
237
238         return null;
239     }
240
241     private static Type handleDpt1(String subType, DPTXlator translator, Class<? extends Type> preferredType) {
242         DPTXlatorBoolean translatorBoolean = (DPTXlatorBoolean) translator;
243         switch (subType) {
244             case "008":
245                 return translatorBoolean.getValueBoolean() ? UpDownType.DOWN : UpDownType.UP;
246             case "009":
247             case "019":
248                 // default is OpenClosedType (Contact), but it may be mapped to OnOffType as well
249                 if (OnOffType.class.equals(preferredType)) {
250                     return OnOffType.from(translatorBoolean.getValueBoolean());
251                 }
252
253                 // This is wrong for DPT 1.009. It should be true -> CLOSE, false -> OPEN, but unfortunately
254                 // can't be fixed without breaking a lot of working installations.
255                 // The documentation has been updated to reflect that. / @J-N-K
256                 return translatorBoolean.getValueBoolean() ? OpenClosedType.OPEN : OpenClosedType.CLOSED;
257             case "010":
258                 return translatorBoolean.getValueBoolean() ? StopMoveType.MOVE : StopMoveType.STOP;
259             case "022":
260                 return DecimalType.valueOf(translatorBoolean.getValueBoolean() ? "1" : "0");
261             default:
262                 // default is OnOffType (Switch), but it may be mapped to OpenClosedType as well
263                 if (OpenClosedType.class.equals(preferredType)) {
264                     return translatorBoolean.getValueBoolean() ? OpenClosedType.OPEN : OpenClosedType.CLOSED;
265                 }
266
267                 return OnOffType.from(translatorBoolean.getValueBoolean());
268         }
269     }
270
271     private static @Nullable Type handleDpt3(String subType, DPTXlator translator) {
272         DPTXlator3BitControlled translator3BitControlled = (DPTXlator3BitControlled) translator;
273         if (translator3BitControlled.getStepCode() == 0) {
274             LOGGER.debug("convertRawDataToType: KNX DPT_Control_Dimming: break received.");
275             return UnDefType.NULL;
276         }
277         switch (subType) {
278             case "007":
279                 return translator3BitControlled.getControlBit() ? IncreaseDecreaseType.INCREASE
280                         : IncreaseDecreaseType.DECREASE;
281             case "008":
282                 return translator3BitControlled.getControlBit() ? UpDownType.DOWN : UpDownType.UP;
283             default:
284                 // should never happen unless Calimero introduces new subtypes
285                 LOGGER.warn("DPT3, subtype '{}' is unknown. Please open an issue.", subType);
286                 return null;
287         }
288     }
289
290     private static Type handleDpt10(String value) throws ParseException {
291         // Calimero will provide either TIME_DAY_FORMAT or TIME_FORMAT, no-day is not printed
292         Date date = null;
293         try {
294             date = new SimpleDateFormat(TIME_DAY_FORMAT, Locale.US).parse(value);
295         } catch (ParseException pe) {
296             date = new SimpleDateFormat(TIME_FORMAT, Locale.US).parse(value);
297         }
298         return DateTimeType.valueOf(new SimpleDateFormat(DateTimeType.DATE_PATTERN).format(date));
299     }
300
301     private static @Nullable Type handleDpt19(DPTXlator translator, byte[] data) throws KNXFormatException {
302         DPTXlatorDateTime translatorDateTime = (DPTXlatorDateTime) translator;
303         if (translatorDateTime.isFaultyClock()) {
304             // Not supported: faulty clock
305             LOGGER.debug("KNX clock msg ignored: clock faulty bit set, which is not supported");
306             return null;
307         } else if (!translatorDateTime.isValidField(DPTXlatorDateTime.YEAR)
308                 && translatorDateTime.isValidField(DPTXlatorDateTime.DATE)) {
309             // Not supported: "/1/1" (month and day without year)
310             LOGGER.debug("KNX clock msg ignored: no year, but day and month, which is not supported");
311             return null;
312         } else if (translatorDateTime.isValidField(DPTXlatorDateTime.YEAR)
313                 && !translatorDateTime.isValidField(DPTXlatorDateTime.DATE)) {
314             // Not supported: "1900" (year without month and day)
315             LOGGER.debug("KNX clock msg ignored: no day and month, but year, which is not supported");
316             return null;
317         } else if (!translatorDateTime.isValidField(DPTXlatorDateTime.YEAR)
318                 && !translatorDateTime.isValidField(DPTXlatorDateTime.DATE)
319                 && !translatorDateTime.isValidField(DPTXlatorDateTime.TIME)) {
320             // Not supported: No year, no date and no time
321             LOGGER.debug("KNX clock msg ignored: no day and month or year, which is not supported");
322             return null;
323         }
324
325         Calendar cal = Calendar.getInstance();
326         if (translatorDateTime.isValidField(DPTXlatorDateTime.YEAR)
327                 && !translatorDateTime.isValidField(DPTXlatorDateTime.TIME)) {
328             // Pure date format, no time information
329             try {
330                 cal.setTimeInMillis(translatorDateTime.getValueMilliseconds());
331             } catch (KNXFormatException e) {
332                 LOGGER.debug("KNX clock msg ignored: {}", e.getMessage());
333                 throw e;
334             }
335             String value = new SimpleDateFormat(DateTimeType.DATE_PATTERN).format(cal.getTime());
336             return DateTimeType.valueOf(value);
337         } else if (!translatorDateTime.isValidField(DPTXlatorDateTime.YEAR)
338                 && translatorDateTime.isValidField(DPTXlatorDateTime.TIME)) {
339             // Pure time format, no date information
340             cal.clear();
341             cal.set(Calendar.HOUR_OF_DAY, translatorDateTime.getHour());
342             cal.set(Calendar.MINUTE, translatorDateTime.getMinute());
343             cal.set(Calendar.SECOND, translatorDateTime.getSecond());
344             String value = new SimpleDateFormat(DateTimeType.DATE_PATTERN).format(cal.getTime());
345             return DateTimeType.valueOf(value);
346         } else if (translatorDateTime.isValidField(DPTXlatorDateTime.YEAR)
347                 && translatorDateTime.isValidField(DPTXlatorDateTime.TIME)) {
348             // Date format and time information
349             try {
350                 cal.setTimeInMillis(translatorDateTime.getValueMilliseconds());
351             } catch (KNXFormatException ignore) {
352                 // throws KNXFormatException in case DST (SUTI) flag does not match calendar
353                 // As the spec regards the SUTI flag as purely informative, flip it and try again.
354                 if (data.length < 8) {
355                     return null;
356                 }
357                 data[6] = (byte) (data[6] ^ 0x01);
358                 translator.setData(data, 0);
359                 cal.setTimeInMillis(translatorDateTime.getValueMilliseconds());
360             }
361             String value = new SimpleDateFormat(DateTimeType.DATE_PATTERN).format(cal.getTime());
362             return DateTimeType.valueOf(value);
363         } else {
364             LOGGER.warn("Failed to convert '{}'", translator.getValue());
365             return null;
366         }
367     }
368
369     private static @Nullable Type handleStringOrDecimal(byte[] data, String value, Class<? extends Type> preferredType,
370             int bits) {
371         if (DecimalType.class.equals(preferredType)) {
372             try {
373                 // need a new translator for 8 bit unsigned, as Calimero handles only the string type
374                 if (bits == 8) {
375                     DPTXlator8BitUnsigned translator = new DPTXlator8BitUnsigned("5.010");
376                     translator.setData(data);
377                     return new DecimalType(translator.getValueUnsigned());
378                 } else if (bits == 16) {
379                     DPTXlator2ByteUnsigned translator = new DPTXlator2ByteUnsigned("7.001");
380                     translator.setData(data);
381                     return new DecimalType(translator.getValueUnsigned());
382                 } else {
383                     return null;
384                 }
385             } catch (KNXFormatException e) {
386                 return null;
387             }
388         } else {
389             return StringType.valueOf(value);
390         }
391     }
392
393     private static @Nullable Type handleDpt232(String value, String subType) {
394         Matcher rgb = RGB_PATTERN.matcher(value);
395         if (rgb.matches()) {
396             int r = Integer.parseInt(rgb.group("r"));
397             int g = Integer.parseInt(rgb.group("g"));
398             int b = Integer.parseInt(rgb.group("b"));
399
400             switch (subType) {
401                 case "600":
402                     return HSBType.fromRGB(r, g, b);
403                 case "60000":
404                     // MDT specific: mis-use 232.600 for hsv instead of rgb
405                     DecimalType hue = new DecimalType(coerceToRange(r * 360.0 / 255.0, 0.0, 359.9999));
406                     PercentType sat = new PercentType(BigDecimal.valueOf(coerceToRange(g / 2.55, 0.0, 100.0)));
407                     PercentType bright = new PercentType(BigDecimal.valueOf(coerceToRange(b / 2.55, 0.0, 100.0)));
408                     return new HSBType(hue, sat, bright);
409                 default:
410                     LOGGER.warn("Unknown subtype '232.{}', no conversion possible.", subType);
411                     return null;
412             }
413         }
414         LOGGER.warn("Failed to convert '{}' (DPT 232): Pattern does not match", value);
415         return null;
416     }
417
418     private static @Nullable Type handleDpt242(String value) {
419         Matcher xyY = XYY_PATTERN.matcher(value);
420         if (xyY.matches()) {
421             String stringx = xyY.group("x");
422             String stringy = xyY.group("y");
423             String stringY = xyY.group("Y");
424
425             if (stringx != null && stringy != null) {
426                 double x = Double.parseDouble(stringx.replace(",", "."));
427                 double y = Double.parseDouble(stringy.replace(",", "."));
428                 if (stringY == null) {
429                     return ColorUtil.xyToHsb(new double[] { x, y });
430                 } else {
431                     double pY = Double.parseDouble(stringY.replace(",", "."));
432                     return ColorUtil.xyToHsb(new double[] { x, y, pY / 100.0 });
433                 }
434             }
435         }
436         LOGGER.warn("Failed to convert '{}' (DPT 242): Pattern does not match", value);
437         return null;
438     }
439
440     private static @Nullable Type handleDpt251(String value, String subType, Class<? extends Type> preferredType) {
441         Matcher rgbw = RGBW_PATTERN.matcher(value);
442         if (rgbw.matches()) {
443             String rString = rgbw.group("r");
444             String gString = rgbw.group("g");
445             String bString = rgbw.group("b");
446             String wString = rgbw.group("w");
447
448             switch (subType) {
449                 case "600":
450                     if (rString != null && gString != null && bString != null && HSBType.class.equals(preferredType)) {
451                         // does not support PercentType and r,g,b valid -> HSBType
452                         int r = coerceToRange((int) (Double.parseDouble(rString.replace(",", ".")) * 2.55), 0, 255);
453                         int g = coerceToRange((int) (Double.parseDouble(gString.replace(",", ".")) * 2.55), 0, 255);
454                         int b = coerceToRange((int) (Double.parseDouble(bString.replace(",", ".")) * 2.55), 0, 255);
455
456                         return HSBType.fromRGB(r, g, b);
457                     } else if (wString != null && PercentType.class.equals(preferredType)) {
458                         // does support PercentType and w valid -> PercentType
459                         BigDecimal w = new BigDecimal(wString.replace(",", "."));
460
461                         return new PercentType(w);
462                     }
463                 case "60600":
464                     // special type used by OH for .600 indicating that RGBW should be handled with a single HSBType,
465                     // typically we use HSBType for RGB and PercentType for W.
466                     if (rString != null && gString != null && bString != null && wString != null
467                             && HSBType.class.equals(preferredType)) {
468                         // does support PercentType and w valid -> PercentType
469                         int r = coerceToRange((int) (Double.parseDouble(rString.replace(",", ".")) * 2.55), 0, 255);
470                         int g = coerceToRange((int) (Double.parseDouble(gString.replace(",", ".")) * 2.55), 0, 255);
471                         int b = coerceToRange((int) (Double.parseDouble(bString.replace(",", ".")) * 2.55), 0, 255);
472                         int w = coerceToRange((int) (Double.parseDouble(wString.replace(",", ".")) * 2.55), 0, 255);
473
474                         return ColorUtil.rgbToHsb(new int[] { r, g, b, w });
475                     }
476                 default:
477                     LOGGER.warn("Unknown subtype '251.{}', no conversion possible.", subType);
478                     return null;
479             }
480
481         }
482         LOGGER.warn("Failed to convert '{}' (DPT 251): Pattern does not match or invalid content", value);
483         return null;
484     }
485
486     private static @Nullable Type handleNumericDpt(String id, DPTXlator translator, Class<? extends Type> preferredType)
487             throws KNXFormatException {
488         Set<Class<? extends Type>> allowedTypes = DPTUtil.getAllowedTypes(id);
489
490         double value = translator.getNumericValue();
491         if (allowedTypes.contains(PercentType.class)
492                 && (HSBType.class.equals(preferredType) || PercentType.class.equals(preferredType))) {
493             return new PercentType(BigDecimal.valueOf(Math.round(value)));
494         }
495
496         if (allowedTypes.contains(QuantityType.class) && !disableUoM) {
497             String unit = DPTUnits.getUnitForDpt(id);
498             if (unit != null) {
499                 if (translator instanceof DPTXlator64BitSigned translatorSigned) {
500                     // prevent loss of precision, do not represent 64bit decimal using double
501                     return new QuantityType<>(translatorSigned.getValueSigned() + " " + unit);
502                 }
503                 return new QuantityType<>(value + " " + unit);
504             } else {
505                 LOGGER.trace("Could not determine unit for DPT '{}', fallback to plain decimal", id);
506             }
507         }
508
509         if (allowedTypes.contains(DecimalType.class)) {
510             if (translator instanceof DPTXlator64BitSigned translatorSigned) {
511                 // prevent loss of precision, do not represent 64bit decimal using double
512                 return new DecimalType(translatorSigned.getValueSigned());
513             }
514             return new DecimalType(value);
515         }
516
517         LOGGER.warn("Failed to convert '{}' (DPT '{}'): no matching type found", value, id);
518         return null;
519     }
520
521     private static double coerceToRange(double value, double min, double max) {
522         return Math.min(Math.max(value, min), max);
523     }
524
525     private static int coerceToRange(int value, int min, int max) {
526         return Math.min(Math.max(value, min), max);
527     }
528 }