]> git.basschouten.com Git - openhab-addons.git/commitdiff
[knx] Add integration tests (#15727)
authorHolger Friedrich <mail@holger-friedrich.de>
Sat, 16 Dec 2023 11:51:14 +0000 (12:51 +0100)
committerGitHub <noreply@github.com>
Sat, 16 Dec 2023 11:51:14 +0000 (12:51 +0100)
* [knx] Add integration tests
* [knx] Adapt handling of DPTs

Signed-off-by: Holger Friedrich <mail@holger-friedrich.de>
bundles/org.openhab.binding.knx/doc/dpt.txt
bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/dpt/DPTUtil.java
bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/dpt/ValueDecoder.java
bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/dpt/ValueEncoder.java
bundles/org.openhab.binding.knx/src/test/java/org/openhab/binding/knx/internal/client/DummyClient.java [new file with mode: 0644]
bundles/org.openhab.binding.knx/src/test/java/org/openhab/binding/knx/internal/client/DummyKNXNetworkLink.java [new file with mode: 0644]
bundles/org.openhab.binding.knx/src/test/java/org/openhab/binding/knx/internal/client/DummyProcessListener.java [new file with mode: 0644]
bundles/org.openhab.binding.knx/src/test/java/org/openhab/binding/knx/internal/dpt/DPTTest.java
bundles/org.openhab.binding.knx/src/test/java/org/openhab/binding/knx/internal/itests/Back2BackTest.java [new file with mode: 0644]

index 78a5428c16fb597012d1381d9151cb0de0e41e71..29ff5371f3b2d85b72f7b14407d6d823825adae0 100644 (file)
@@ -46,8 +46,8 @@ MainType: 3
 3.008: DPT_Control_Blinds values: 0 = up 1 = down
 
 MainType: 4
-4.001: DPT_Char_ASCII
-4.002: DPT_Char_8859_1
+unsupported 4.001: DPT_Char_ASCII
+unsupported 4.002: DPT_Char_8859_1
 
 MainType: 5
 5.000: General byte
index f3c6823dc9b0e7e1b81091ccaa6c4af79cb533a1..296883a8f4b7b929168d516cc3fa7784a6f57d42 100644 (file)
@@ -70,22 +70,28 @@ public class DPTUtil {
             Map.entry("9", Set.of(QuantityType.class, DecimalType.class)), //
             Map.entry("10", Set.of(DateTimeType.class)), //
             Map.entry("11", Set.of(DateTimeType.class)), //
-            Map.entry("12", Set.of(DecimalType.class)), //
+            Map.entry("12", Set.of(QuantityType.class, DecimalType.class)), //
             Map.entry("13", Set.of(QuantityType.class, DecimalType.class)), //
             Map.entry("14", Set.of(QuantityType.class, DecimalType.class)), //
             Map.entry("16", Set.of(StringType.class)), //
             Map.entry("17", Set.of(DecimalType.class)), //
             Map.entry("18", Set.of(DecimalType.class)), //
             Map.entry("19", Set.of(DateTimeType.class)), //
-            Map.entry("20", Set.of(StringType.class)), //
-            Map.entry("21", Set.of(StringType.class)), //
-            Map.entry("22", Set.of(StringType.class)), //
+            Map.entry("20", Set.of(StringType.class, DecimalType.class)), //
+            Map.entry("21", Set.of(StringType.class, DecimalType.class)), //
+            Map.entry("22", Set.of(StringType.class, DecimalType.class)), //
             Map.entry("28", Set.of(StringType.class)), //
             Map.entry("29", Set.of(QuantityType.class, DecimalType.class)), //
             Map.entry("229", Set.of(DecimalType.class)), //
             Map.entry("232", Set.of(HSBType.class)), //
             Map.entry("242", Set.of(HSBType.class)), //
-            Map.entry("251", Set.of(HSBType.class, PercentType.class)));
+            Map.entry("243", Set.of(StringType.class)), //
+            Map.entry("249", Set.of(StringType.class)), //
+            Map.entry("250", Set.of(StringType.class)), //
+            Map.entry("251", Set.of(HSBType.class, PercentType.class)), //
+            Map.entry("252", Set.of(StringType.class)), //
+            Map.entry("253", Set.of(StringType.class)), //
+            Map.entry("254", Set.of(StringType.class)));
 
     // compatible types for full DPTs
     private static final Map<String, Set<Class<? extends Type>>> DPT_TYPE_MAP = Map.ofEntries(
index a54490ca3a7da913bcd9332fe264940aa20ffc63..641ef1589d6faa8e49dc6f616345b8bb490c0a3d 100644 (file)
@@ -48,7 +48,10 @@ import tuwien.auto.calimero.KNXFormatException;
 import tuwien.auto.calimero.KNXIllegalArgumentException;
 import tuwien.auto.calimero.dptxlator.DPTXlator;
 import tuwien.auto.calimero.dptxlator.DPTXlator1BitControlled;
+import tuwien.auto.calimero.dptxlator.DPTXlator2ByteUnsigned;
 import tuwien.auto.calimero.dptxlator.DPTXlator3BitControlled;
+import tuwien.auto.calimero.dptxlator.DPTXlator64BitSigned;
+import tuwien.auto.calimero.dptxlator.DPTXlator8BitUnsigned;
 import tuwien.auto.calimero.dptxlator.DPTXlatorBoolean;
 import tuwien.auto.calimero.dptxlator.DPTXlatorDateTime;
 import tuwien.auto.calimero.dptxlator.DPTXlatorSceneControl;
@@ -134,13 +137,22 @@ public class ValueDecoder {
                     }
                     return new DecimalType(decimalValue);
                 case "19":
-                    return handleDpt19(translator);
-                case "16":
+                    return handleDpt19(translator, data);
                 case "20":
                 case "21":
+                    return handleStringOrDecimal(data, value, preferredType, 8);
                 case "22":
+                    return handleStringOrDecimal(data, value, preferredType, 16);
+                case "16":
                 case "28":
+                case "250": // Map all combined color transitions to String,
+                case "252": // as no native support is planned.
+                case "253": // Currently only one subtype 2xx.600
+                case "254": // is defined for those DPTs.
                     return StringType.valueOf(value);
+                case "243": // color translation, fix regional
+                case "249": // settings
+                    return StringType.valueOf(value.replace(',', '.').replace(". ", ", "));
                 case "232":
                     return handleDpt232(value, subType);
                 case "242":
@@ -149,6 +161,7 @@ public class ValueDecoder {
                     return handleDpt251(value, preferredType);
                 default:
                     return handleNumericDpt(id, translator, preferredType);
+                // TODO 6.001 is mapped to PercentType, which can only cover 0-100%, not -128..127%
             }
         } catch (NumberFormatException | KNXFormatException | KNXIllegalArgumentException | ParseException e) {
             LOGGER.info("Translator couldn't parse data '{}' for datapoint type '{}' ({}).", data, dptId, e.getClass());
@@ -198,19 +211,10 @@ public class ValueDecoder {
     }
 
     private static Type handleDpt10(String value) throws ParseException {
-        if (value.contains("no-day")) {
-            /*
-             * KNX "no-day" needs special treatment since openHAB's DateTimeType doesn't support "no-day".
-             * Workaround: remove the "no-day" String, parse the remaining time string, which will result in a
-             * date of "1970-01-01".
-             * Replace "no-day" with the current day name
-             */
-            StringBuilder stb = new StringBuilder(value);
-            int start = stb.indexOf("no-day");
-            int end = start + "no-day".length();
-            stb.replace(start, end, String.format(Locale.US, "%1$ta", Calendar.getInstance()));
-            value = stb.toString();
-        }
+        // TODO check handling of DPT10: date is not set to current date, but 1970-01-01 + offset if day is given
+        // maybe we should change the semantics and use current date + offset if day is given
+
+        // Calimero will provide either TIME_DAY_FORMAT or TIME_FORMAT, no-day is not printed
         Date date = null;
         try {
             date = new SimpleDateFormat(TIME_DAY_FORMAT, Locale.US).parse(value);
@@ -220,7 +224,7 @@ public class ValueDecoder {
         return DateTimeType.valueOf(new SimpleDateFormat(DateTimeType.DATE_PATTERN).format(date));
     }
 
-    private static @Nullable Type handleDpt19(DPTXlator translator) throws KNXFormatException {
+    private static @Nullable Type handleDpt19(DPTXlator translator, byte[] data) throws KNXFormatException {
         DPTXlatorDateTime translatorDateTime = (DPTXlatorDateTime) translator;
         if (translatorDateTime.isFaultyClock()) {
             // Not supported: faulty clock
@@ -263,7 +267,18 @@ public class ValueDecoder {
         } else if (translatorDateTime.isValidField(DPTXlatorDateTime.YEAR)
                 && translatorDateTime.isValidField(DPTXlatorDateTime.TIME)) {
             // Date format and time information
-            cal.setTimeInMillis(translatorDateTime.getValueMilliseconds());
+            try {
+                cal.setTimeInMillis(translatorDateTime.getValueMilliseconds());
+            } catch (KNXFormatException ignore) {
+                // throws KNXFormatException in case DST (SUTI) flag does not match calendar
+                // As the spec regards the SUTI flag as purely informative, flip it and try again.
+                if (data.length < 8) {
+                    return null;
+                }
+                data[6] = (byte) (data[6] ^ 0x01);
+                translator.setData(data, 0);
+                cal.setTimeInMillis(translatorDateTime.getValueMilliseconds());
+            }
             String value = new SimpleDateFormat(DateTimeType.DATE_PATTERN).format(cal.getTime());
             return DateTimeType.valueOf(value);
         } else {
@@ -272,6 +287,30 @@ public class ValueDecoder {
         }
     }
 
+    private static @Nullable Type handleStringOrDecimal(byte[] data, String value, Class<? extends Type> preferredType,
+            int bits) {
+        if (DecimalType.class.equals(preferredType)) {
+            try {
+                // need a new translator for 8 bit unsigned, as Calimero handles only the string type
+                if (bits == 8) {
+                    DPTXlator8BitUnsigned translator = new DPTXlator8BitUnsigned("5.010");
+                    translator.setData(data);
+                    return new DecimalType(translator.getValueUnsigned());
+                } else if (bits == 16) {
+                    DPTXlator2ByteUnsigned translator = new DPTXlator2ByteUnsigned("7.001");
+                    translator.setData(data);
+                    return new DecimalType(translator.getValueUnsigned());
+                } else {
+                    return null;
+                }
+            } catch (KNXFormatException e) {
+                return null;
+            }
+        } else {
+            return StringType.valueOf(value);
+        }
+    }
+
     private static @Nullable Type handleDpt232(String value, String subType) {
         Matcher rgb = RGB_PATTERN.matcher(value);
         if (rgb.matches()) {
@@ -358,6 +397,10 @@ public class ValueDecoder {
         if (allowedTypes.contains(QuantityType.class) && !disableUoM) {
             String unit = DPTUnits.getUnitForDpt(id);
             if (unit != null) {
+                if (translator instanceof DPTXlator64BitSigned translatorSigned) {
+                    // prevent loss of precision, do not represent 64bit decimal using double
+                    return new QuantityType<>(translatorSigned.getValueSigned() + " " + unit);
+                }
                 return new QuantityType<>(value + " " + unit);
             } else {
                 LOGGER.trace("Could not determine unit for DPT '{}', fallback to plain decimal", id);
@@ -365,6 +408,10 @@ public class ValueDecoder {
         }
 
         if (allowedTypes.contains(DecimalType.class)) {
+            if (translator instanceof DPTXlator64BitSigned translatorSigned) {
+                // prevent loss of precision, do not represent 64bit decimal using double
+                return new DecimalType(translatorSigned.getValueSigned());
+            }
             return new DecimalType(value);
         }
 
index c2519d3bf719ffd3343a49ea1acf13bc712995cc..50666c5da2dc4e2544929a8e224946b26444aedb 100644 (file)
@@ -16,6 +16,7 @@ import static org.openhab.binding.knx.internal.dpt.DPTUtil.NORMALIZED_DPT;
 
 import java.math.BigDecimal;
 import java.math.RoundingMode;
+import java.text.DecimalFormat;
 import java.util.Locale;
 import java.util.regex.Matcher;
 
@@ -108,6 +109,10 @@ public class ValueEncoder {
             } else if (value instanceof DecimalType || value instanceof QuantityType<?>) {
                 return handleNumericTypes(dptId, mainNumber, dpt, value);
             } else if (value instanceof StringType) {
+                if ("243.600".equals(dptId) || "249.600".equals(dptId)) {
+                    return value.toString().replace('.', ((DecimalFormat) DecimalFormat.getInstance())
+                            .getDecimalFormatSymbols().getDecimalSeparator());
+                }
                 return value.toString();
             } else if (value instanceof DateTimeType type) {
                 return handleDateTimeType(dptId, type);
diff --git a/bundles/org.openhab.binding.knx/src/test/java/org/openhab/binding/knx/internal/client/DummyClient.java b/bundles/org.openhab.binding.knx/src/test/java/org/openhab/binding/knx/internal/client/DummyClient.java
new file mode 100644 (file)
index 0000000..94e231d
--- /dev/null
@@ -0,0 +1,42 @@
+/**
+ * Copyright (c) 2010-2023 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.knx.internal.client;
+
+import java.util.Collections;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.knx.internal.handler.KNXBridgeBaseThingHandler.CommandExtensionData;
+import org.openhab.core.thing.ThingUID;
+
+import tuwien.auto.calimero.KNXException;
+import tuwien.auto.calimero.link.KNXNetworkLink;
+
+/**
+ * {@link AbstractKNXClient} implementation for test, using {@link DummyKNXNetworkLink}.
+ *
+ * @author Holger Friedrich - initial contribution and API.
+ *
+ */
+@NonNullByDefault
+public class DummyClient extends AbstractKNXClient {
+
+    public DummyClient() {
+        super(0, new ThingUID("dummy connection"), 0, 0, 0, null, new CommandExtensionData(Collections.emptyMap()),
+                null);
+    }
+
+    @Override
+    protected KNXNetworkLink establishConnection() throws KNXException, InterruptedException {
+        return new DummyKNXNetworkLink();
+    }
+}
diff --git a/bundles/org.openhab.binding.knx/src/test/java/org/openhab/binding/knx/internal/client/DummyKNXNetworkLink.java b/bundles/org.openhab.binding.knx/src/test/java/org/openhab/binding/knx/internal/client/DummyKNXNetworkLink.java
new file mode 100644 (file)
index 0000000..5a9b32c
--- /dev/null
@@ -0,0 +1,134 @@
+/**
+ * Copyright (c) 2010-2023 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.knx.internal.client;
+
+import java.util.HashSet;
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.util.HexUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import tuwien.auto.calimero.FrameEvent;
+import tuwien.auto.calimero.IndividualAddress;
+import tuwien.auto.calimero.KNXAddress;
+import tuwien.auto.calimero.KNXTimeoutException;
+import tuwien.auto.calimero.Priority;
+import tuwien.auto.calimero.cemi.CEMILData;
+import tuwien.auto.calimero.link.KNXLinkClosedException;
+import tuwien.auto.calimero.link.KNXNetworkLink;
+import tuwien.auto.calimero.link.NetworkLinkListener;
+import tuwien.auto.calimero.link.medium.KNXMediumSettings;
+
+/**
+ * This class provides a simulated KNXNetworkLink with test stubs for integration tests.
+ *
+ * See Calimero documentation, calimero-ng.pdf.
+ *
+ * Frames sent via {@link #sendRequest()} and {@link sendRequestWait()} will be looped back
+ * to all registered listeners. {@link #getLastFrame()} will return the binary data provided
+ * to the last send command.
+ *
+ * @author Holger Friedrich - Initial contribution
+ */
+@NonNullByDefault({})
+public class DummyKNXNetworkLink implements KNXNetworkLink {
+    public static final Logger LOGGER = LoggerFactory.getLogger(DummyKNXNetworkLink.class);
+    public static final int GROUP_WRITE = 0x80;
+
+    private byte[] lastFrame = new byte[0];
+    private Set<NetworkLinkListener> listeners = new HashSet<>();
+
+    public void setKNXMedium(KNXMediumSettings settings) {
+        LOGGER.warn(settings.toString());
+    }
+
+    public KNXMediumSettings getKNXMedium() {
+        return KNXMediumSettings.create(KNXMediumSettings.MEDIUM_TP1, new IndividualAddress(1, 2, 3));
+    }
+
+    public void addLinkListener(NetworkLinkListener l) {
+        listeners.add(l);
+    }
+
+    public void removeLinkListener(NetworkLinkListener l) {
+        listeners.remove(l);
+    }
+
+    public void setHopCount(int count) {
+    }
+
+    public int getHopCount() {
+        return 0;
+    }
+
+    public void sendRequest(KNXAddress dst, Priority p, byte[] nsdu)
+            throws KNXTimeoutException, KNXLinkClosedException {
+        sendRequestWait(dst, p, nsdu);
+    }
+
+    public void sendRequestWait(KNXAddress dst, Priority p, byte[] nsdu)
+            throws KNXTimeoutException, KNXLinkClosedException {
+        LOGGER.info("sendRequestWait() {} {} {}", dst, p, HexUtils.bytesToHex(nsdu, " "));
+
+        lastFrame = nsdu.clone();
+
+        // not we want to mimic a received frame by looping it back to all listeners
+
+        /*
+         * relevant steps to create a CEMI frame needed for triggering a frame event:
+         *
+         * final CEMILData f = (CEMILData) e.getFrame();
+         * final var apdu = f.getPayload();
+         * final int svc = DataUnitBuilder.getAPDUService(apdu);
+         * svc == GROUP_WRITE
+         * fireGroupReadWrite(f, DataUnitBuilder.extractASDU(apdu), svc, apdu.length <= 2);
+         * send(CEMILData.MC_LDATA_IND, dst, p, nsdu, true);
+         */
+        int service = GROUP_WRITE;
+        byte[] apdu = new byte[nsdu.length + 2];
+        apdu[0] = (byte) (service >> 8);
+        apdu[1] = (byte) service;
+        System.arraycopy(nsdu, 0, apdu, 2, nsdu.length);
+
+        final IndividualAddress src = new IndividualAddress(1, 1, 1);
+        final boolean repeat = false;
+        final int hopCount = 1;
+
+        FrameEvent f = new FrameEvent(this, new CEMILData(CEMILData.MC_LDATA_IND, src, dst, nsdu, p, repeat, hopCount));
+
+        listeners.forEach(listener -> {
+            listener.indication(f);
+        });
+    }
+
+    public void send(CEMILData msg, boolean waitForCon) throws KNXTimeoutException, KNXLinkClosedException {
+        LOGGER.warn("send() not implemented");
+    }
+
+    public String getName() {
+        return "dummy link";
+    }
+
+    public boolean isOpen() {
+        return true;
+    }
+
+    public void close() {
+    }
+
+    public byte[] getLastFrame() {
+        return lastFrame;
+    }
+}
diff --git a/bundles/org.openhab.binding.knx/src/test/java/org/openhab/binding/knx/internal/client/DummyProcessListener.java b/bundles/org.openhab.binding.knx/src/test/java/org/openhab/binding/knx/internal/client/DummyProcessListener.java
new file mode 100644 (file)
index 0000000..7ca1c06
--- /dev/null
@@ -0,0 +1,81 @@
+/**
+ * Copyright (c) 2010-2023 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.knx.internal.client;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import tuwien.auto.calimero.DetachEvent;
+import tuwien.auto.calimero.process.ProcessEvent;
+import tuwien.auto.calimero.process.ProcessListener;
+
+/**
+ * This implementation of {@link ProcessListener} caches a received frames.
+ *
+ * It can be registered to {@link DummyKNXNetworkLink} to receive raw frame data.
+ *
+ * @author Holger Friedrich - Initial contribution
+ *
+ */
+@NonNullByDefault
+public class DummyProcessListener implements ProcessListener {
+    private byte[] lastFrame = new byte[0];
+    public static final Logger LOGGER = LoggerFactory.getLogger(DummyProcessListener.class);
+
+    public DummyProcessListener() {
+    }
+
+    @Override
+    public void detached(@Nullable DetachEvent e) {
+        LOGGER.info("The KNX network link was detached from the process communicator");
+    }
+
+    @Override
+    public void groupWrite(@Nullable ProcessEvent e) {
+        if (e == null) {
+            lastFrame = new byte[0];
+            LOGGER.warn("invalid ProcessEvent");
+            return;
+        }
+        LOGGER.info("groupWrite({})", e.toString());
+        lastFrame = e.getASDU(); // clones
+    }
+
+    @Override
+    public void groupReadRequest(@Nullable ProcessEvent e) {
+        if (e == null) {
+            lastFrame = new byte[0];
+            LOGGER.warn("invalid ProcessEvent");
+            return;
+        }
+        LOGGER.warn("groupReadRequest({})", e.toString());
+        lastFrame = e.getASDU(); // clones
+    }
+
+    @Override
+    public void groupReadResponse(@Nullable ProcessEvent e) {
+        if (e == null) {
+            lastFrame = new byte[0];
+            LOGGER.warn("invalid ProcessEvent");
+            return;
+        }
+        LOGGER.warn("groupReadResponse({})", e.toString());
+        lastFrame = e.getASDU(); // clones
+    }
+
+    public byte[] getLastFrame() {
+        return lastFrame;
+    }
+}
index f969b265d65ced246d93be6705d6a6ec4305bf5f..d07941c5d83ca39d3716d92273c95cc9e27e0a8b 100644 (file)
@@ -31,6 +31,7 @@ import org.openhab.core.library.types.HSBType;
 import org.openhab.core.library.types.QuantityType;
 import org.openhab.core.library.unit.SIUnits;
 import org.openhab.core.library.unit.Units;
+import org.openhab.core.util.ColorUtil;
 
 import tuwien.auto.calimero.dptxlator.DPTXlator2ByteUnsigned;
 import tuwien.auto.calimero.dptxlator.DPTXlator4ByteFloat;
@@ -330,7 +331,29 @@ class DPTTest {
     }
 
     @Test
-    public void dpt252EncoderTest() {
+    public void dpt251White() {
+        // input data: color white
+        byte[] data = new byte[] { (byte) 0xff, (byte) 0xff, (byte) 0xff, 0x00, 0x00, 0x0e };
+        HSBType hsbType = (HSBType) ValueDecoder.decode("251.600", data, HSBType.class);
+
+        assertNotNull(hsbType);
+        assertEquals(0, hsbType.getHue().doubleValue(), 0.5);
+        assertEquals(0, hsbType.getSaturation().doubleValue(), 0.5);
+        assertEquals(100, hsbType.getBrightness().doubleValue(), 0.5);
+
+        String enc = ValueEncoder.encode(hsbType, "251.600");
+        // white should be "100 100 100 - %", but expect small deviation due to rounding
+        assertNotNull(enc);
+        String[] parts = enc.split(" ");
+        assertEquals(5, parts.length);
+        int[] rgb = ColorUtil.hsbToRgb(hsbType);
+        assertEquals(rgb[0] * 100d / 255, Double.valueOf(parts[0].replace(',', '.')), 1);
+        assertEquals(rgb[1] * 100d / 255, Double.valueOf(parts[1].replace(',', '.')), 1);
+        assertEquals(rgb[2] * 100d / 255, Double.valueOf(parts[2].replace(',', '.')), 1);
+    }
+
+    @Test
+    public void dpt251Value() {
         // input data
         byte[] data = new byte[] { 0x26, 0x2b, 0x31, 0x00, 0x00, 0x0e };
         HSBType hsbType = (HSBType) ValueDecoder.decode("251.600", data, HSBType.class);
@@ -339,6 +362,16 @@ class DPTTest {
         assertEquals(207, hsbType.getHue().doubleValue(), 0.5);
         assertEquals(23, hsbType.getSaturation().doubleValue(), 0.5);
         assertEquals(19, hsbType.getBrightness().doubleValue(), 0.5);
+
+        String enc = ValueEncoder.encode(hsbType, "251.600");
+        // white should be "100 100 100 - %", but expect small deviation due to rounding
+        assertNotNull(enc);
+        String[] parts = enc.split(" ");
+        assertEquals(5, parts.length);
+        int[] rgb = ColorUtil.hsbToRgb(hsbType);
+        assertEquals(rgb[0] * 100d / 255, Double.valueOf(parts[0].replace(',', '.')), 1);
+        assertEquals(rgb[1] * 100d / 255, Double.valueOf(parts[1].replace(',', '.')), 1);
+        assertEquals(rgb[2] * 100d / 255, Double.valueOf(parts[2].replace(',', '.')), 1);
     }
 
     // This test checks all our overrides for units. It allows to detect unnecessary overrides when we
diff --git a/bundles/org.openhab.binding.knx/src/test/java/org/openhab/binding/knx/internal/itests/Back2BackTest.java b/bundles/org.openhab.binding.knx/src/test/java/org/openhab/binding/knx/internal/itests/Back2BackTest.java
new file mode 100644 (file)
index 0000000..3722d26
--- /dev/null
@@ -0,0 +1,557 @@
+/**
+ * Copyright (c) 2010-2023 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.knx.internal.itests;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Objects;
+import java.util.Set;
+
+import javax.measure.quantity.Temperature;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.knx.internal.client.DummyKNXNetworkLink;
+import org.openhab.binding.knx.internal.client.DummyProcessListener;
+import org.openhab.binding.knx.internal.dpt.DPTUtil;
+import org.openhab.binding.knx.internal.dpt.ValueDecoder;
+import org.openhab.binding.knx.internal.dpt.ValueEncoder;
+import org.openhab.core.library.types.DateTimeType;
+import org.openhab.core.library.types.DecimalType;
+import org.openhab.core.library.types.HSBType;
+import org.openhab.core.library.types.IncreaseDecreaseType;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.OpenClosedType;
+import org.openhab.core.library.types.PercentType;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.types.StopMoveType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.library.types.UpDownType;
+import org.openhab.core.types.Type;
+import org.openhab.core.util.ColorUtil;
+import org.openhab.core.util.HexUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import tuwien.auto.calimero.DataUnitBuilder;
+import tuwien.auto.calimero.GroupAddress;
+import tuwien.auto.calimero.KNXException;
+import tuwien.auto.calimero.datapoint.CommandDP;
+import tuwien.auto.calimero.datapoint.Datapoint;
+import tuwien.auto.calimero.dptxlator.TranslatorTypes;
+import tuwien.auto.calimero.process.ProcessCommunicator;
+import tuwien.auto.calimero.process.ProcessCommunicatorImpl;
+
+/**
+ * Integration test to check conversion from raw KNX frame data to OH data types and back.
+ *
+ * This test checks
+ * <ul>
+ * <li>if OH can properly decode raw data payload from KNX frames using {@link ValueDecoder#decode()},
+ * <li>if OH can properly encode the data for handover to Calimero using {@link ValueEncoder#encode()},
+ * <li>if Calimero supports and correctly handles the data conversion to raw bytes for sending.
+ * </ul>
+ *
+ * In addition, it checks if newly integrated releases of Calimero introduce new DPT types not yet
+ * handled by this test. However, new subtypes are not detected.
+ *
+ * @see DummyKNXNetworkLink
+ * @see DummyClient
+ * @author Holger Friedrich - Initial contribution
+ *
+ */
+@NonNullByDefault
+public class Back2BackTest {
+    public static final Logger LOGGER = LoggerFactory.getLogger(Back2BackTest.class);
+    static Set<Integer> dptTested = new HashSet<>();
+    boolean testsMissing = false;
+
+    /**
+     * helper method for integration tests
+     *
+     * @param dpt DPT type, e.g. "251.600", see 03_07_02-Datapoint-Types-v02.02.01-AS.pdf
+     * @param rawData byte array containing raw data, known content
+     * @param ohReferenceData OpenHAB data type, initialized to known good value
+     * @param maxDistance byte array containing maximal deviations when comparing byte arrays (rawData against created
+     *            KNX frame), may be empty if no deviation is considered
+     * @param bitmask to mask certain bits in the raw to raw comparison, required for multi-valued KNX frames
+     */
+    void helper(String dpt, byte[] rawData, Type ohReferenceData, byte[] maxDistance, byte[] bitmask) {
+        try {
+            DummyKNXNetworkLink link = new DummyKNXNetworkLink();
+            ProcessCommunicator pc = new ProcessCommunicatorImpl(link);
+            DummyProcessListener processListener = new DummyProcessListener();
+            pc.addProcessListener(processListener);
+
+            GroupAddress groupAddress = new GroupAddress(2, 4, 6);
+            Datapoint datapoint = new CommandDP(groupAddress, "dummy GA", 0,
+                    DPTUtil.NORMALIZED_DPT.getOrDefault(dpt, dpt));
+
+            // 0) check usage of helper()
+            assertEquals(true, rawData.length > 0);
+            if (maxDistance.length == 0) {
+                maxDistance = new byte[rawData.length];
+            }
+            assertEquals(rawData.length, maxDistance.length, "incorrect length of maxDistance array");
+            if (bitmask.length == 0) {
+                bitmask = new byte[rawData.length];
+                Arrays.fill(bitmask, (byte) 0xff);
+            }
+            assertEquals(rawData.length, bitmask.length, "incorrect length of bitmask array");
+            int mainType = Integer.parseUnsignedInt(dpt.substring(0, dpt.indexOf('.')));
+            dptTested.add(Integer.valueOf(mainType));
+            // check if OH would be able to send out a frame, given the type
+            Set<Integer> knownWorking = Set.of(1, 3, 5);
+            if (!knownWorking.contains(mainType)) {
+                Set<Class<? extends Type>> allowedTypes = DPTUtil.getAllowedTypes("" + mainType);
+                if (!allowedTypes.contains(ohReferenceData.getClass())) {
+                    LOGGER.warn(
+                            "test for DPT {} uses type {} which is not contained in DPT_TYPE_MAP, sending may not be allowed",
+                            dpt, ohReferenceData.getClass());
+                }
+            }
+
+            // 1) check if the decoder works (rawData to known good type ohReferenceData)
+            //
+            // This test is based on known raw data. The mapping to openHAB type is known and confirmed.
+            // In this test, only ValueDecoder.decode() is involved.
+
+            // raw data of the DPT on application layer, without all headers from the layers below
+            // see 03_07_02-Datapoint-Types-v02.02.01-AS.pdf
+            Type ohData = (Type) ValueDecoder.decode(dpt, rawData, ohReferenceData.getClass());
+            assertNotNull(ohData, "could not decode frame data for DPT " + dpt);
+            if ((ohReferenceData instanceof HSBType hsbReferenceData) && (ohData instanceof HSBType hsbData)) {
+                assertTrue(hsbReferenceData.closeTo(hsbData, 0.001),
+                        "comparing reference to decoded value for DPT " + dpt);
+            } else {
+                assertEquals(ohReferenceData, ohData, "comparing reference to decoded value: failed for DPT " + dpt
+                        + ", check ValueEncoder.decode()");
+            }
+
+            // 2) check the encoding (ohData to raw data)
+            //
+            // Test approach is to a) encode the value into String format using ValueEncoder.encode(),
+            // b) pass it to Calimero for conversion into a raw representation, and
+            // c) finally grab raw data bytes from a custom KNXNetworkLink implementation
+            String enc = ValueEncoder.encode(ohData, dpt);
+            pc.write(datapoint, enc);
+
+            byte[] frame = link.getLastFrame();
+            assertNotNull(frame);
+            // remove header; for compact frames extract data byte from header
+            frame = DataUnitBuilder.extractASDU(frame);
+            assertEquals(rawData.length, frame.length,
+                    "unexpected length of KNX frame: " + HexUtils.bytesToHex(frame, " "));
+            for (int i = 0; i < rawData.length; i++) {
+                assertEquals(rawData[i] & bitmask[i] & 0xff, frame[i] & bitmask[i] & 0xff, maxDistance[i],
+                        "unexpected content in encoded data, " + i);
+            }
+
+            // 3) Check date provided by Calimero library as input via loopback, it should match the initial data
+            //
+            // Deviations in some bytes of the frame may be possible due to data conversion, e.g. for HSBType.
+            // This is why maxDistance is used.
+            byte[] input = processListener.getLastFrame();
+            LOGGER.info("loopback {}", HexUtils.bytesToHex(input, " "));
+            assertNotNull(input);
+            assertEquals(rawData.length, input.length, "unexpected length of loopback frame");
+            for (int i = 0; i < rawData.length; i++) {
+                assertEquals(rawData[i] & bitmask[i] & 0xff, input[i] & bitmask[i] & 0xff, maxDistance[i],
+                        "unexpected content in loopback data, " + i);
+            }
+
+            pc.close();
+        } catch (KNXException e) {
+            LOGGER.warn("exception occurred", e.toString());
+            assertEquals("", e.toString());
+        }
+    }
+
+    void helper(String dpt, byte[] rawData, Type ohReferenceData) {
+        helper(dpt, rawData, ohReferenceData, new byte[0], new byte[0]);
+    }
+
+    @Test
+    void testDpt1() {
+        // for now only the DPTs for general use, others omitted
+        // TODO add tests for more subtypes
+
+        helper("1.001", new byte[] { 0 }, OnOffType.OFF);
+        helper("1.001", new byte[] { 1 }, OnOffType.ON);
+        helper("1.002", new byte[] { 0 }, OnOffType.OFF);
+        helper("1.002", new byte[] { 1 }, OnOffType.ON);
+        helper("1.003", new byte[] { 0 }, OnOffType.OFF);
+        helper("1.003", new byte[] { 1 }, OnOffType.ON);
+
+        helper("1.008", new byte[] { 0 }, UpDownType.UP);
+        helper("1.008", new byte[] { 1 }, UpDownType.DOWN);
+        // NOTE: This is how DPT 1.009 is defined: 0: open, 1: closed
+        // For historical reasons it is defined the other way on OH
+        helper("1.009", new byte[] { 0 }, OpenClosedType.CLOSED);
+        helper("1.009", new byte[] { 1 }, OpenClosedType.OPEN);
+        helper("1.010", new byte[] { 0 }, StopMoveType.STOP);
+        helper("1.010", new byte[] { 1 }, StopMoveType.MOVE);
+
+        helper("1.015", new byte[] { 0 }, OnOffType.OFF);
+        helper("1.015", new byte[] { 1 }, OnOffType.ON);
+        helper("1.016", new byte[] { 0 }, OnOffType.OFF);
+        helper("1.016", new byte[] { 1 }, OnOffType.ON);
+        // DPT 1.017 is a special case, "trigger" has no "value", both 0 and 1 shall trigger
+        helper("1.017", new byte[] { 0 }, OnOffType.OFF);
+        // Calimero maps it always to 0
+        // helper("1.017", new byte[] { 1 }, OnOffType.ON);
+        helper("1.018", new byte[] { 0 }, OnOffType.OFF);
+        helper("1.018", new byte[] { 1 }, OnOffType.ON);
+        helper("1.019", new byte[] { 0 }, OpenClosedType.CLOSED);
+        helper("1.019", new byte[] { 1 }, OpenClosedType.OPEN);
+
+        helper("1.024", new byte[] { 0 }, OnOffType.OFF);
+        helper("1.024", new byte[] { 1 }, OnOffType.ON);
+    }
+
+    @Test
+    void testDpt2() {
+        for (int subType = 1; subType <= 12; subType++) {
+            helper("2." + String.format("%03d", subType), new byte[] { 3 }, new DecimalType(3));
+        }
+    }
+
+    @Test
+    void testDpt3() {
+        // DPT 3.007 and DPT 3.008 consist of a control bit (1 bit) and stepsize (3 bit)
+        // if stepsize is 0, OH will ignore the command
+        byte controlBit = 1 << 3;
+        // loop all other step sizes and check only the control bit
+        for (byte i = 1; i < 8; i++) {
+            helper("3.007", new byte[] { i }, IncreaseDecreaseType.DECREASE, new byte[0], new byte[] { controlBit });
+            helper("3.007", new byte[] { (byte) (i + controlBit) }, IncreaseDecreaseType.INCREASE, new byte[0],
+                    new byte[] { controlBit });
+            helper("3.008", new byte[] { i }, UpDownType.UP, new byte[0], new byte[] { controlBit });
+            helper("3.008", new byte[] { (byte) (i + controlBit) }, UpDownType.DOWN, new byte[0],
+                    new byte[] { controlBit });
+        }
+
+        // check if OH ignores incoming frames with mask 0 (mapped to UndefType)
+        Assertions.assertFalse(ValueDecoder.decode("3.007", new byte[] { 0 },
+                IncreaseDecreaseType.class) instanceof IncreaseDecreaseType);
+        Assertions.assertFalse(ValueDecoder.decode("3.007", new byte[] { controlBit },
+                IncreaseDecreaseType.class) instanceof IncreaseDecreaseType);
+        Assertions.assertFalse(ValueDecoder.decode("3.008", new byte[] { 0 }, UpDownType.class) instanceof UpDownType);
+        Assertions.assertFalse(
+                ValueDecoder.decode("3.008", new byte[] { controlBit }, UpDownType.class) instanceof UpDownType);
+    }
+
+    @Test
+    void testDpt5() {
+        // TODO add tests for more subtypes
+        helper("5.001", new byte[] { 0 }, new PercentType(0));
+        helper("5.001", new byte[] { (byte) 0x80 }, new PercentType(50));
+        helper("5.001", new byte[] { (byte) 0xff }, new PercentType(100));
+
+        helper("5.010", new byte[] { 42 }, new DecimalType(42));
+        helper("5.010", new byte[] { (byte) 0xff }, new DecimalType(255));
+    }
+
+    @Test
+    void testDpt6() {
+        helper("6.010", new byte[] { 0 }, new DecimalType(0));
+        helper("6.010", new byte[] { (byte) 0x7f }, new DecimalType(127));
+        helper("6.010", new byte[] { (byte) 0xff }, new DecimalType(-1));
+        // TODO 6.001 is mapped to PercentType, which can only cover 0-100%, not -128..127%
+        // helper("6.001", new byte[] { 0 }, new DecimalType(0));
+    }
+
+    @Test
+    void testDpt7() {
+        // TODO add tests for more subtypes
+        helper("7.001", new byte[] { 0, 42 }, new DecimalType(42));
+        helper("7.001", new byte[] { (byte) 0xff, (byte) 0xff }, new DecimalType(65535));
+    }
+
+    @Test
+    void testDpt8() {
+        // TODO add tests for more subtypes
+        helper("8.001", new byte[] { (byte) 0x7f, (byte) 0xff }, new DecimalType(32767));
+        helper("8.001", new byte[] { (byte) 0x80, (byte) 0x00 }, new DecimalType(-32768));
+    }
+
+    @Test
+    void testDpt9() {
+        // TODO add tests for more subtypes
+        helper("9.001", new byte[] { (byte) 0x00, (byte) 0x64 }, new QuantityType<Temperature>("1 °C"));
+    }
+
+    @Test
+    void testDpt10() {
+        // TODO check handling of DPT10: date is not set to current date, but 1970-01-01 + offset if day is given
+        // maybe we should change the semantics and use current date + offset if day is given
+
+        // note: local timezone is set when creating DateTimeType, for example "1970-01-01Thh:mm:ss.000+0100"
+
+        // no-day
+        assertTrue(Objects
+                .toString(ValueDecoder.decode("10.001", new byte[] { (byte) 0x11, (byte) 0x1e, 0 }, DecimalType.class))
+                .startsWith("1970-01-01T17:30:00.000+"));
+        // Thursday, this is correct for 1970-01-01
+        assertTrue(Objects
+                .toString(ValueDecoder.decode("10.001", new byte[] { (byte) 0x91, (byte) 0x1e, 0 }, DecimalType.class))
+                .startsWith("1970-01-01T17:30:00.000+"));
+        // Monday -> 1970-01-05
+        assertTrue(Objects
+                .toString(ValueDecoder.decode("10.001", new byte[] { (byte) 0x31, (byte) 0x1e, 0 }, DecimalType.class))
+                .startsWith("1970-01-05T17:30:00.000+"));
+
+        // Thursday, otherwise first byte of encoded data will not match
+        helper("10.001", new byte[] { (byte) 0x91, (byte) 0x1e, (byte) 0x0 }, new DateTimeType("17:30:00"));
+        helper("10.001", new byte[] { (byte) 0x11, (byte) 0x1e, (byte) 0x0 }, new DateTimeType("17:30:00"), new byte[0],
+                new byte[] { (byte) 0x1f, (byte) 0xff, (byte) 0xff });
+    }
+
+    @Test
+    void testDpt11() {
+        // note: local timezone and dst is set when creating DateTimeType, for example "2019-06-12T00:00:00.000+0200"
+        helper("11.001", new byte[] { (byte) 12, 6, 19 }, new DateTimeType("2019-06-12"));
+    }
+
+    @Test
+    void testDpt12() {
+        helper("12.001", new byte[] { (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xfe },
+                new DecimalType("4294967294"));
+        helper("12.100", new byte[] { 0, 0, 0, 60 }, new QuantityType<>("60 s"));
+        helper("12.100", new byte[] { 0, 0, 0, 60 }, new QuantityType<>("1 min"));
+        helper("12.101", new byte[] { 0, 0, 0, 60 }, new QuantityType<>("60 min"));
+        helper("12.101", new byte[] { 0, 0, 0, 60 }, new QuantityType<>("1 h"));
+        helper("12.102", new byte[] { 0, 0, 0, 1 }, new QuantityType<>("1 h"));
+        helper("12.102", new byte[] { 0, 0, 0, 1 }, new QuantityType<>("60 min"));
+
+        helper("12.1200", new byte[] { 0, 0, 0, 1 }, new QuantityType<>("1 l"));
+        helper("12.1200", new byte[] { (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xfe },
+                new QuantityType<>("4294967294 l"));
+        helper("12.1201", new byte[] { 0, 0, 0, 1 }, new QuantityType<>("1 m³"));
+        helper("12.1201", new byte[] { (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xfe },
+                new QuantityType<>("4294967294 m³"));
+    }
+
+    @Test
+    void testDpt13() {
+        // TODO add tests for more subtypes
+        helper("13.001", new byte[] { 0, 0, 0, 0 }, new DecimalType(0));
+        helper("13.001", new byte[] { 0, 0, 0, 42 }, new DecimalType(42));
+        helper("13.001", new byte[] { (byte) 0x7f, (byte) 0xff, (byte) 0xff, (byte) 0xff },
+                new DecimalType(2147483647));
+        // KNX representation typically uses two's complement
+        helper("13.001", new byte[] { (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff }, new DecimalType(-1));
+        helper("13.001", new byte[] { (byte) 0x80, (byte) 0x0, (byte) 0x0, (byte) 0x0 }, new DecimalType(-2147483648));
+    }
+
+    @Test
+    void testDpt14() {
+        // TODO add tests for more subtypes
+        helper("14.068", new byte[] { (byte) 0x3f, (byte) 0x80, 0, 0 }, new QuantityType<Temperature>("1 °C"));
+    }
+
+    @Test
+    void testDpt16() {
+        helper("16.000", new byte[] { (byte) 0x4B, (byte) 0x4E, 0x58, 0x20, 0x69, 0x73, 0x20, (byte) 0x4F, (byte) 0x4B,
+                0x0, 0x0, 0x0, 0x0, 0x0 }, new StringType("KNX is OK"));
+        helper("16.001", new byte[] { (byte) 0x4B, (byte) 0x4E, 0x58, 0x20, 0x69, 0x73, 0x20, (byte) 0x4F, (byte) 0x4B,
+                0x0, 0x0, 0x0, 0x0, 0x0 }, new StringType("KNX is OK"));
+    }
+
+    @Test
+    void testDpt17() {
+        helper("17.001", new byte[] { 0 }, new DecimalType(0));
+        helper("17.001", new byte[] { 42 }, new DecimalType(42));
+        helper("17.001", new byte[] { 63 }, new DecimalType(63));
+    }
+
+    @Test
+    void testDpt18() {
+        // scene, activate 0..63
+        helper("18.001", new byte[] { 0 }, new DecimalType(0));
+        helper("18.001", new byte[] { 42 }, new DecimalType(42));
+        helper("18.001", new byte[] { 63 }, new DecimalType(63));
+        // scene, learn += 0x80
+        helper("18.001", new byte[] { (byte) (0x80 + 0) }, new DecimalType(0x80));
+        helper("18.001", new byte[] { (byte) (0x80 + 42) }, new DecimalType(0x80 + 42));
+        helper("18.001", new byte[] { (byte) (0x80 + 63) }, new DecimalType(0x80 + 63));
+    }
+
+    @Test
+    void testDpt19() {
+        // 2019-01-15 17:30:00
+        helper("19.001", new byte[] { (byte) (2019 - 1900), 1, 15, 17, 30, 0, (byte) 0x25, (byte) 0x00 },
+                new DateTimeType("2019-01-15T17:30:00"));
+        helper("19.001", new byte[] { (byte) (2019 - 1900), 1, 15, 17, 30, 0, (byte) 0x24, (byte) 0x00 },
+                new DateTimeType("2019-01-15T17:30:00"));
+        // 2019-07-15 17:30:00
+        helper("19.001", new byte[] { (byte) (2019 - 1900), 7, 15, 17, 30, 0, (byte) 0x25, (byte) 0x00 },
+                new DateTimeType("2019-07-15T17:30:00"), new byte[0], new byte[] { 0, 0, 0, 0, 0, 0, 0, 1 });
+        helper("19.001", new byte[] { (byte) (2019 - 1900), 7, 15, 17, 30, 0, (byte) 0x24, (byte) 0x00 },
+                new DateTimeType("2019-07-15T17:30:00"), new byte[0], new byte[] { 0, 0, 0, 0, 0, 0, 0, 1 });
+    }
+
+    @Test
+    void testDpt20() {
+        // test default String representation of enum (incomplete)
+        helper("20.001", new byte[] { 0 }, new StringType("autonomous"));
+        helper("20.001", new byte[] { 1 }, new StringType("slave"));
+        helper("20.001", new byte[] { 2 }, new StringType("master"));
+
+        helper("20.002", new byte[] { 0 }, new StringType("building in use"));
+        helper("20.002", new byte[] { 1 }, new StringType("building not used"));
+        helper("20.002", new byte[] { 2 }, new StringType("building protection"));
+
+        // test DecimalType representation of enum
+        int[] subTypes = new int[] { 1, 2, 3, 4, 5, 6, 7, 8, 11, 12, 13, 14, 17, 20, 21, 100, 101, 102, 103, 104, 105,
+                106, 107, 108, 109, 110, 111, 112, 113, 114, 120, 121, 122, 600, 601, 602, 603, 604, 605, 606, 607, 608,
+                609, 610, 801, 802, 803, 804, 1000, 1001, 1002, 1003, 1004, 1005, 1200, 1202 };
+        for (int subType : subTypes) {
+            helper("20." + String.format("%03d", subType), new byte[] { 1 }, new DecimalType(1));
+        }
+        // once these DPTs are available in Calimero, add to check above
+        int[] unsupportedSubTypes = new int[] { 22, 115, 611, 612, 613, 1203, 1204, 1205, 1206, 1207, 1208, 1209 };
+        for (int subType : unsupportedSubTypes) {
+            assertNull(ValueDecoder.decode("20." + String.format("%03d", subType), new byte[] { 0 }, StringType.class));
+        }
+    }
+
+    @Test
+    void testDpt21() {
+        // test default String representation of bitfield (incomplete)
+        helper("21.001", new byte[] { 5 }, new StringType("overridden, out of service"));
+
+        // test DecimalType representation of bitfield
+        int[] subTypes = new int[] { 1, 2, 100, 101, 102, 103, 104, 105, 106, 601, 1000, 1001, 1002, 1010 };
+        for (int subType : subTypes) {
+            helper("21." + String.format("%03d", subType), new byte[] { 1 }, new DecimalType(1));
+        }
+        // once these DPTs are available in Calimero, add to check above
+        assertNull(ValueDecoder.decode("21.1200", new byte[] { 0 }, StringType.class));
+        assertNull(ValueDecoder.decode("21.1201", new byte[] { 0 }, StringType.class));
+    }
+
+    @Test
+    void testDpt22() {
+        // test default String representation of bitfield (incomplete)
+        helper("22.101", new byte[] { 1, 0 }, new StringType("heating mode"));
+        helper("22.101", new byte[] { 1, 2 }, new StringType("heating mode, heating eco mode"));
+
+        // test DecimalType representation of bitfield
+        helper("22.101", new byte[] { 0, 2 }, new DecimalType(2));
+        helper("22.1000", new byte[] { 0, 2 }, new DecimalType(2));
+        // once these DPTs are available in Calimero, add to check above
+        assertNull(ValueDecoder.decode("22.100", new byte[] { 0, 2 }, StringType.class));
+        assertNull(ValueDecoder.decode("22.1010", new byte[] { 0, 2 }, StringType.class));
+    }
+
+    @Test
+    void testDpt28() {
+        // null terminated strings, UTF8
+        helper("28.001", new byte[] { 0x31, 0x32, 0x33, 0x34, 0x0 }, new StringType("1234"));
+        helper("28.001", new byte[] { (byte) 0xce, (byte) 0xb5, 0x34, 0x0 }, new StringType("\u03b54"));
+    }
+
+    @Test
+    void testDpt29() {
+        helper("29.010", new byte[] { 0, 0, 0, 0, 0, 0, 0, 42 }, new QuantityType<>("42 Wh"));
+        helper("29.010", new byte[] { (byte) 0x80, 0, 0, 0, 0, 0, 0, 0 },
+                new QuantityType<>("-9223372036854775808 Wh"));
+        helper("29.010", new byte[] { (byte) 0xff, 0, 0, 0, 0, 0, 0, 0 }, new QuantityType<>("-72057594037927936 Wh"));
+        helper("29.010", new byte[] { 0, 0, 0, 0, 0, 0, 0, 42 }, new QuantityType<>("42 Wh"));
+        helper("29.011", new byte[] { 0, 0, 0, 0, 0, 0, 0, 42 }, new QuantityType<>("42 VAh"));
+        helper("29.012", new byte[] { 0, 0, 0, 0, 0, 0, 0, 42 }, new QuantityType<>("42 varh"));
+    }
+
+    @Test
+    void testDpt229() {
+        // special DPT for metering, allows several units and different scaling
+        // -> Calimero uses scaling, but always encodes as dimensionless value
+        final int dimensionlessCounter = 0b10111010;
+        helper("229.001", new byte[] { 0, 0, 0, 0, (byte) dimensionlessCounter, 0 }, new DecimalType(0));
+    }
+
+    @Test
+    void testColorDpts() {
+        // HSB
+        helper("232.600", new byte[] { 123, 45, 67 }, ColorUtil.rgbToHsb(new int[] { 123, 45, 67 }));
+        // RGB, MDT specific
+        helper("232.60000", new byte[] { 123, 45, 67 }, new HSBType("173.6, 17.6, 26.3"));
+
+        // xyY
+        int x = (int) (14.65 * 65535.0 / 100.0);
+        int y = (int) (11.56 * 65535.0 / 100.0);
+        // encoding is always xy and brightness (C+B, 0x03), do not test other combinations
+        helper("242.600", new byte[] { (byte) ((x >> 8) & 0xff), (byte) (x & 0xff), (byte) ((y >> 8) & 0xff),
+                (byte) (y & 0xff), (byte) 0x28, 0x3 }, new HSBType("220,90,50"), new byte[] { 0, 8, 0, 8, 0, 0 },
+                new byte[0]);
+        // TODO check brightness
+
+        // RGBW, only RGB part
+        helper("251.600", new byte[] { 0x26, 0x2b, 0x31, 0x00, 0x00, 0x0e }, new HSBType("207, 23, 19"),
+                new byte[] { 1, 1, 1, 0, 0, 0 }, new byte[0]);
+        // RGBW, only RGB part
+        helper("251.600", new byte[] { (byte) 0xff, (byte) 0xff, (byte) 0xff, 0x00, 0x00, 0x0e },
+                new HSBType("0, 0, 100"), new byte[] { 1, 1, 1, 0, 0, 0 }, new byte[0]);
+    }
+
+    @Test
+    void testColorTransitionDpts() {
+        // DPT 243.600 DPT_Colour_Transition_xyY
+        // time(2) y(2) x(2), %brightness(1), flags(1)
+        helper("243.600", new byte[] { 0, 5, 0x7F, 0, (byte) 0xfe, 0, 42, 3 },
+                new StringType("(0.9922, 0.4961) 16.5 % 0.5 s"));
+        // DPT 249.600 DPT_Brightness_Colour_Temperature_Transition
+        // time(2) colortemp(2), brightness(1), flags(1)
+        helper("249.600", new byte[] { 0, 5, 0, 40, 127, 7 }, new StringType("49.8 % 40 K 0.5 s"));
+        // DPT 250.600 DPT_Brightness_Colour_Temperature_Control
+        // cct(1) cb(1) flags(1)
+        helper("250.600", new byte[] { 0x0f, 0x0e, 3 }, new StringType("CT increase 7 steps BRT increase 6 steps"));
+        // DPT 252.600 DPT_Relative_Control_RGBW
+        // r(1) g(1) b(1) w(1) flags(1)
+        helper("252.600", new byte[] { 0x0f, 0x0e, 0x0d, 0x0c, 0x0f },
+                new StringType("R increase 7 steps G increase 6 steps B increase 5 steps W increase 4 steps"));
+        // DPT 253.600 DPT_Relative_Control_xyY
+        // cs(1) ct(1) cb(1) flags(1)
+        helper("253.600", new byte[] { 0x0f, 0x0e, 0x0d, 0x7 },
+                new StringType("x increase 7 steps y increase 6 steps Y increase 5 steps"));
+        // DPT 254.600 DPT_Relative_Control_RGB
+        // cr(1) cg(1) cb(1)
+        helper("254.600", new byte[] { 0x0f, 0x0e, 0x0d },
+                new StringType("R increase 7 steps G increase 6 steps B increase 5 steps"));
+    }
+
+    @Test
+    @AfterAll
+    static void checkForMissingMainTypes() {
+        // checks if we have itests for all main DPT types supported by Calimero library,
+        // data is collected within method helper()
+        var wrapper = new Object() {
+            boolean testsMissing = false;
+        };
+        TranslatorTypes.getAllMainTypes().forEach((i, t) -> {
+            if (!dptTested.contains(i)) {
+                LOGGER.warn("missing tests for main DPT type " + i);
+                wrapper.testsMissing = true;
+            }
+        });
+        assertEquals(false, wrapper.testsMissing, "add tests for new DPT main types");
+    }
+}