2 * Copyright (c) 2010-2024 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.knx.internal.dpt;
15 import static org.openhab.binding.knx.internal.KNXBindingConstants.disableUoM;
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;
24 import java.util.regex.Matcher;
25 import java.util.regex.Pattern;
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;
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;
61 * This class decodes raw data received from the KNX bus to an openHAB datatype
63 * Parts of this code are based on the openHAB KNXCoreTypeMapper by Kai Kreuzer et al.
65 * @author Jan N. Klug - Initial contribution
68 public class ValueDecoder {
69 private static final Logger LOGGER = LoggerFactory.getLogger(ValueDecoder.class);
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
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].*");
85 private static boolean check235001(byte[] data) throws KNXException {
86 if (data.length != 6) {
87 throw new KNXFormatException("DPT235 broken frame");
89 if ((data[5] & 2) == 0) {
90 LOGGER.trace("DPT235.001 w/o ActiveEnergy ignored");
96 private static boolean check23561001(byte[] data) throws KNXException {
97 if (data.length != 6) {
98 throw new KNXFormatException("DPT235 broken frame");
100 if ((data[5] & 1) == 0) {
101 LOGGER.trace("DPT235.61001 w/o Tariff ignored");
108 * convert the raw value received to the corresponding openHAB value
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)
115 public static @Nullable Type decode(String dptId, byte[] data, Class<? extends Type> preferredType) {
118 String translatorDptId = dptId;
119 DPTXlator translator;
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)) {
131 translator = TranslatorTypes.createTranslator(0, "13.010");
132 translator.setData(data);
133 value = translator.getValue();
135 translatorDptId = dptId;
136 } else if ("235.61001".equals(dptId)) {
137 if (!check23561001(data)) {
140 translator = TranslatorTypes.createTranslator(0, "5.006");
141 translator.setData(new byte[] { data[4] });
142 value = translator.getValue();
144 translatorDptId = dptId;
146 // no known special case, handle unknown translator outer try block
150 String id = dptId; // prefer using the user-supplied DPT
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",
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);
163 LOGGER.trace("Finally using datapoint DPT = {}", id);
165 String mainType = m.group("main");
166 String subType = m.group("sub");
170 return handleDpt1(subType, translator, preferredType);
172 DPTXlator1BitControlled translator1BitControlled = (DPTXlator1BitControlled) translator;
173 int decValue = (translator1BitControlled.getControlBit() ? 2 : 0)
174 + (translator1BitControlled.getValueBit() ? 1 : 0);
175 return new DecimalType(decValue);
177 return handleDpt3(subType, translator);
179 if ("020".equals(subType)) {
180 return handleStringOrDecimal(data, value, preferredType, 8);
182 return handleNumericDpt(id, translator, preferredType);
185 return handleDpt10(value);
187 return DateTimeType.valueOf(new SimpleDateFormat(DateTimeType.DATE_PATTERN)
188 .format(new SimpleDateFormat(DATE_FORMAT).parse(value)));
190 DPTXlatorSceneControl translatorSceneControl = (DPTXlatorSceneControl) translator;
191 int decimalValue = translatorSceneControl.getSceneNumber();
192 if (value.startsWith("learn")) {
193 decimalValue += 0x80;
195 return new DecimalType(decimalValue);
197 return handleDpt19(translator, data);
200 return handleStringOrDecimal(data, value, preferredType, 8);
202 return handleStringOrDecimal(data, value, preferredType, 16);
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 for (; mt.matches(); mt = TSD_SEPARATOR.matcher(time)) {
218 int dp = time.indexOf(mt.group("sep"));
219 time = time.substring(0, dp) + time.substring(dp + 1);
221 value = value.substring(0, sep + 3) + time;
222 return StringType.valueOf(value.replace(',', '.').replace(". ", ", "));
224 return handleDpt232(value, subType);
226 return handleDpt242(value);
228 return handleDpt251(value, subType, preferredType);
230 return handleNumericDpt(id, translator, preferredType);
232 } catch (NumberFormatException | KNXFormatException | KNXIllegalArgumentException | ParseException e) {
233 LOGGER.info("Translator couldn't parse data '{}' for datapoint type '{}' ({}).", data, dptId, e.getClass());
234 } catch (KNXException e) {
235 // should never happen unless Calimero changes
236 LOGGER.warn("Failed creating a translator for datapoint type '{}'. Please open an issue.", dptId, e);
242 private static Type handleDpt1(String subType, DPTXlator translator, Class<? extends Type> preferredType) {
243 DPTXlatorBoolean translatorBoolean = (DPTXlatorBoolean) translator;
246 return translatorBoolean.getValueBoolean() ? UpDownType.DOWN : UpDownType.UP;
249 // default is OpenClosedType (Contact), but it may be mapped to OnOffType as well
250 if (OnOffType.class.equals(preferredType)) {
251 return OnOffType.from(translatorBoolean.getValueBoolean());
254 // This is wrong for DPT 1.009. It should be true -> CLOSE, false -> OPEN, but unfortunately
255 // can't be fixed without breaking a lot of working installations.
256 // The documentation has been updated to reflect that. / @J-N-K
257 return translatorBoolean.getValueBoolean() ? OpenClosedType.OPEN : OpenClosedType.CLOSED;
259 return translatorBoolean.getValueBoolean() ? StopMoveType.MOVE : StopMoveType.STOP;
261 return DecimalType.valueOf(translatorBoolean.getValueBoolean() ? "1" : "0");
263 // default is OnOffType (Switch), but it may be mapped to OpenClosedType as well
264 if (OpenClosedType.class.equals(preferredType)) {
265 return translatorBoolean.getValueBoolean() ? OpenClosedType.OPEN : OpenClosedType.CLOSED;
268 return OnOffType.from(translatorBoolean.getValueBoolean());
272 private static @Nullable Type handleDpt3(String subType, DPTXlator translator) {
273 DPTXlator3BitControlled translator3BitControlled = (DPTXlator3BitControlled) translator;
274 if (translator3BitControlled.getStepCode() == 0) {
275 LOGGER.debug("convertRawDataToType: KNX DPT_Control_Dimming: break received.");
276 return UnDefType.NULL;
280 return translator3BitControlled.getControlBit() ? IncreaseDecreaseType.INCREASE
281 : IncreaseDecreaseType.DECREASE;
283 return translator3BitControlled.getControlBit() ? UpDownType.DOWN : UpDownType.UP;
285 // should never happen unless Calimero introduces new subtypes
286 LOGGER.warn("DPT3, subtype '{}' is unknown. Please open an issue.", subType);
291 private static Type handleDpt10(String value) throws ParseException {
292 // Calimero will provide either TIME_DAY_FORMAT or TIME_FORMAT, no-day is not printed
295 date = new SimpleDateFormat(TIME_DAY_FORMAT, Locale.US).parse(value);
296 } catch (ParseException pe) {
297 date = new SimpleDateFormat(TIME_FORMAT, Locale.US).parse(value);
299 return DateTimeType.valueOf(new SimpleDateFormat(DateTimeType.DATE_PATTERN).format(date));
302 private static @Nullable Type handleDpt19(DPTXlator translator, byte[] data) throws KNXFormatException {
303 DPTXlatorDateTime translatorDateTime = (DPTXlatorDateTime) translator;
304 if (translatorDateTime.isFaultyClock()) {
305 // Not supported: faulty clock
306 LOGGER.debug("KNX clock msg ignored: clock faulty bit set, which is not supported");
308 } else if (!translatorDateTime.isValidField(DPTXlatorDateTime.YEAR)
309 && translatorDateTime.isValidField(DPTXlatorDateTime.DATE)) {
310 // Not supported: "/1/1" (month and day without year)
311 LOGGER.debug("KNX clock msg ignored: no year, but day and month, which is not supported");
313 } else if (translatorDateTime.isValidField(DPTXlatorDateTime.YEAR)
314 && !translatorDateTime.isValidField(DPTXlatorDateTime.DATE)) {
315 // Not supported: "1900" (year without month and day)
316 LOGGER.debug("KNX clock msg ignored: no day and month, but year, which is not supported");
318 } else if (!translatorDateTime.isValidField(DPTXlatorDateTime.YEAR)
319 && !translatorDateTime.isValidField(DPTXlatorDateTime.DATE)
320 && !translatorDateTime.isValidField(DPTXlatorDateTime.TIME)) {
321 // Not supported: No year, no date and no time
322 LOGGER.debug("KNX clock msg ignored: no day and month or year, which is not supported");
326 Calendar cal = Calendar.getInstance();
327 if (translatorDateTime.isValidField(DPTXlatorDateTime.YEAR)
328 && !translatorDateTime.isValidField(DPTXlatorDateTime.TIME)) {
329 // Pure date format, no time information
331 cal.setTimeInMillis(translatorDateTime.getValueMilliseconds());
332 } catch (KNXFormatException e) {
333 LOGGER.debug("KNX clock msg ignored: {}", e.getMessage());
336 String value = new SimpleDateFormat(DateTimeType.DATE_PATTERN).format(cal.getTime());
337 return DateTimeType.valueOf(value);
338 } else if (!translatorDateTime.isValidField(DPTXlatorDateTime.YEAR)
339 && translatorDateTime.isValidField(DPTXlatorDateTime.TIME)) {
340 // Pure time format, no date information
342 cal.set(Calendar.HOUR_OF_DAY, translatorDateTime.getHour());
343 cal.set(Calendar.MINUTE, translatorDateTime.getMinute());
344 cal.set(Calendar.SECOND, translatorDateTime.getSecond());
345 String value = new SimpleDateFormat(DateTimeType.DATE_PATTERN).format(cal.getTime());
346 return DateTimeType.valueOf(value);
347 } else if (translatorDateTime.isValidField(DPTXlatorDateTime.YEAR)
348 && translatorDateTime.isValidField(DPTXlatorDateTime.TIME)) {
349 // Date format and time information
351 cal.setTimeInMillis(translatorDateTime.getValueMilliseconds());
352 } catch (KNXFormatException ignore) {
353 // throws KNXFormatException in case DST (SUTI) flag does not match calendar
354 // As the spec regards the SUTI flag as purely informative, flip it and try again.
355 if (data.length < 8) {
358 data[6] = (byte) (data[6] ^ 0x01);
359 translator.setData(data, 0);
360 cal.setTimeInMillis(translatorDateTime.getValueMilliseconds());
362 String value = new SimpleDateFormat(DateTimeType.DATE_PATTERN).format(cal.getTime());
363 return DateTimeType.valueOf(value);
365 LOGGER.warn("Failed to convert '{}'", translator.getValue());
370 private static @Nullable Type handleStringOrDecimal(byte[] data, String value, Class<? extends Type> preferredType,
372 if (DecimalType.class.equals(preferredType)) {
374 // need a new translator for 8 bit unsigned, as Calimero handles only the string type
376 DPTXlator8BitUnsigned translator = new DPTXlator8BitUnsigned("5.010");
377 translator.setData(data);
378 return new DecimalType(translator.getValueUnsigned());
379 } else if (bits == 16) {
380 DPTXlator2ByteUnsigned translator = new DPTXlator2ByteUnsigned("7.001");
381 translator.setData(data);
382 return new DecimalType(translator.getValueUnsigned());
386 } catch (KNXFormatException e) {
390 return StringType.valueOf(value);
394 private static @Nullable Type handleDpt232(String value, String subType) {
395 Matcher rgb = RGB_PATTERN.matcher(value);
397 int r = Integer.parseInt(rgb.group("r"));
398 int g = Integer.parseInt(rgb.group("g"));
399 int b = Integer.parseInt(rgb.group("b"));
403 return HSBType.fromRGB(r, g, b);
405 // MDT specific: mis-use 232.600 for hsv instead of rgb
406 DecimalType hue = new DecimalType(coerceToRange(r * 360.0 / 255.0, 0.0, 359.9999));
407 PercentType sat = new PercentType(BigDecimal.valueOf(coerceToRange(g / 2.55, 0.0, 100.0)));
408 PercentType bright = new PercentType(BigDecimal.valueOf(coerceToRange(b / 2.55, 0.0, 100.0)));
409 return new HSBType(hue, sat, bright);
411 LOGGER.warn("Unknown subtype '232.{}', no conversion possible.", subType);
415 LOGGER.warn("Failed to convert '{}' (DPT 232): Pattern does not match", value);
419 private static @Nullable Type handleDpt242(String value) {
420 Matcher xyY = XYY_PATTERN.matcher(value);
422 String stringx = xyY.group("x");
423 String stringy = xyY.group("y");
424 String stringY = xyY.group("Y");
426 if (stringx != null && stringy != null) {
427 double x = Double.parseDouble(stringx.replace(",", "."));
428 double y = Double.parseDouble(stringy.replace(",", "."));
429 if (stringY == null) {
430 return ColorUtil.xyToHsb(new double[] { x, y });
432 double pY = Double.parseDouble(stringY.replace(",", "."));
433 return ColorUtil.xyToHsb(new double[] { x, y, pY / 100.0 });
437 LOGGER.warn("Failed to convert '{}' (DPT 242): Pattern does not match", value);
441 private static @Nullable Type handleDpt251(String value, String subType, Class<? extends Type> preferredType) {
442 Matcher rgbw = RGBW_PATTERN.matcher(value);
443 if (rgbw.matches()) {
444 String rString = rgbw.group("r");
445 String gString = rgbw.group("g");
446 String bString = rgbw.group("b");
447 String wString = rgbw.group("w");
451 if (rString != null && gString != null && bString != null && HSBType.class.equals(preferredType)) {
452 // does not support PercentType and r,g,b valid -> HSBType
453 int r = coerceToRange((int) (Double.parseDouble(rString.replace(",", ".")) * 2.55), 0, 255);
454 int g = coerceToRange((int) (Double.parseDouble(gString.replace(",", ".")) * 2.55), 0, 255);
455 int b = coerceToRange((int) (Double.parseDouble(bString.replace(",", ".")) * 2.55), 0, 255);
457 return HSBType.fromRGB(r, g, b);
458 } else if (wString != null && PercentType.class.equals(preferredType)) {
459 // does support PercentType and w valid -> PercentType
460 BigDecimal w = new BigDecimal(wString.replace(",", "."));
462 return new PercentType(w);
465 // special type used by OH for .600 indicating that RGBW should be handled with a single HSBType,
466 // typically we use HSBType for RGB and PercentType for W.
467 if (rString != null && gString != null && bString != null && wString != null
468 && HSBType.class.equals(preferredType)) {
469 // does support PercentType and w valid -> PercentType
470 int r = coerceToRange((int) (Double.parseDouble(rString.replace(",", ".")) * 2.55), 0, 255);
471 int g = coerceToRange((int) (Double.parseDouble(gString.replace(",", ".")) * 2.55), 0, 255);
472 int b = coerceToRange((int) (Double.parseDouble(bString.replace(",", ".")) * 2.55), 0, 255);
473 int w = coerceToRange((int) (Double.parseDouble(wString.replace(",", ".")) * 2.55), 0, 255);
475 return ColorUtil.rgbToHsb(new int[] { r, g, b, w });
478 LOGGER.warn("Unknown subtype '251.{}', no conversion possible.", subType);
483 LOGGER.warn("Failed to convert '{}' (DPT 251): Pattern does not match or invalid content", value);
487 private static @Nullable Type handleNumericDpt(String id, DPTXlator translator, Class<? extends Type> preferredType)
488 throws KNXFormatException {
489 Set<Class<? extends Type>> allowedTypes = DPTUtil.getAllowedTypes(id);
491 double value = translator.getNumericValue();
492 if (allowedTypes.contains(PercentType.class)
493 && (HSBType.class.equals(preferredType) || PercentType.class.equals(preferredType))) {
494 return new PercentType(BigDecimal.valueOf(Math.round(value)));
497 if (allowedTypes.contains(QuantityType.class) && !disableUoM) {
498 String unit = DPTUnits.getUnitForDpt(id);
500 if (translator instanceof DPTXlator64BitSigned translatorSigned) {
501 // prevent loss of precision, do not represent 64bit decimal using double
502 return new QuantityType<>(translatorSigned.getValueSigned() + " " + unit);
504 return new QuantityType<>(value + " " + unit);
506 LOGGER.trace("Could not determine unit for DPT '{}', fallback to plain decimal", id);
510 if (allowedTypes.contains(DecimalType.class)) {
511 if (translator instanceof DPTXlator64BitSigned translatorSigned) {
512 // prevent loss of precision, do not represent 64bit decimal using double
513 return new DecimalType(translatorSigned.getValueSigned());
515 return new DecimalType(value);
518 LOGGER.warn("Failed to convert '{}' (DPT '{}'): no matching type found", value, id);
522 private static double coerceToRange(double value, double min, double max) {
523 return Math.min(Math.max(value, min), max);
526 private static int coerceToRange(int value, int min, int max) {
527 return Math.min(Math.max(value, min), max);