From 4ba325d0df399a31cf93a771a9f5e8c0f3214239 Mon Sep 17 00:00:00 2001 From: Holger Friedrich Date: Sat, 16 Dec 2023 12:51:14 +0100 Subject: [PATCH] [knx] Add integration tests (#15727) * [knx] Add integration tests * [knx] Adapt handling of DPTs Signed-off-by: Holger Friedrich --- bundles/org.openhab.binding.knx/doc/dpt.txt | 4 +- .../binding/knx/internal/dpt/DPTUtil.java | 16 +- .../knx/internal/dpt/ValueDecoder.java | 81 ++- .../knx/internal/dpt/ValueEncoder.java | 5 + .../knx/internal/client/DummyClient.java | 42 ++ .../internal/client/DummyKNXNetworkLink.java | 134 +++++ .../internal/client/DummyProcessListener.java | 81 +++ .../binding/knx/internal/dpt/DPTTest.java | 35 +- .../knx/internal/itests/Back2BackTest.java | 557 ++++++++++++++++++ 9 files changed, 930 insertions(+), 25 deletions(-) create mode 100644 bundles/org.openhab.binding.knx/src/test/java/org/openhab/binding/knx/internal/client/DummyClient.java create mode 100644 bundles/org.openhab.binding.knx/src/test/java/org/openhab/binding/knx/internal/client/DummyKNXNetworkLink.java create mode 100644 bundles/org.openhab.binding.knx/src/test/java/org/openhab/binding/knx/internal/client/DummyProcessListener.java create mode 100644 bundles/org.openhab.binding.knx/src/test/java/org/openhab/binding/knx/internal/itests/Back2BackTest.java diff --git a/bundles/org.openhab.binding.knx/doc/dpt.txt b/bundles/org.openhab.binding.knx/doc/dpt.txt index 78a5428c16f..29ff5371f3b 100644 --- a/bundles/org.openhab.binding.knx/doc/dpt.txt +++ b/bundles/org.openhab.binding.knx/doc/dpt.txt @@ -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 diff --git a/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/dpt/DPTUtil.java b/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/dpt/DPTUtil.java index f3c6823dc9b..296883a8f4b 100644 --- a/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/dpt/DPTUtil.java +++ b/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/dpt/DPTUtil.java @@ -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>> DPT_TYPE_MAP = Map.ofEntries( diff --git a/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/dpt/ValueDecoder.java b/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/dpt/ValueDecoder.java index a54490ca3a7..641ef1589d6 100644 --- a/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/dpt/ValueDecoder.java +++ b/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/dpt/ValueDecoder.java @@ -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 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); } diff --git a/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/dpt/ValueEncoder.java b/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/dpt/ValueEncoder.java index c2519d3bf71..50666c5da2d 100644 --- a/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/dpt/ValueEncoder.java +++ b/bundles/org.openhab.binding.knx/src/main/java/org/openhab/binding/knx/internal/dpt/ValueEncoder.java @@ -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 index 00000000000..94e231dd3f5 --- /dev/null +++ b/bundles/org.openhab.binding.knx/src/test/java/org/openhab/binding/knx/internal/client/DummyClient.java @@ -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 index 00000000000..5a9b32cf2b1 --- /dev/null +++ b/bundles/org.openhab.binding.knx/src/test/java/org/openhab/binding/knx/internal/client/DummyKNXNetworkLink.java @@ -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 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 index 00000000000..7ca1c062d8e --- /dev/null +++ b/bundles/org.openhab.binding.knx/src/test/java/org/openhab/binding/knx/internal/client/DummyProcessListener.java @@ -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; + } +} diff --git a/bundles/org.openhab.binding.knx/src/test/java/org/openhab/binding/knx/internal/dpt/DPTTest.java b/bundles/org.openhab.binding.knx/src/test/java/org/openhab/binding/knx/internal/dpt/DPTTest.java index f969b265d65..d07941c5d83 100644 --- a/bundles/org.openhab.binding.knx/src/test/java/org/openhab/binding/knx/internal/dpt/DPTTest.java +++ b/bundles/org.openhab.binding.knx/src/test/java/org/openhab/binding/knx/internal/dpt/DPTTest.java @@ -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 index 00000000000..3722d268f50 --- /dev/null +++ b/bundles/org.openhab.binding.knx/src/test/java/org/openhab/binding/knx/internal/itests/Back2BackTest.java @@ -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 + *
    + *
  • if OH can properly decode raw data payload from KNX frames using {@link ValueDecoder#decode()}, + *
  • if OH can properly encode the data for handover to Calimero using {@link ValueEncoder#encode()}, + *
  • if Calimero supports and correctly handles the data conversion to raw bytes for sending. + *
+ * + * 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 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 knownWorking = Set.of(1, 3, 5); + if (!knownWorking.contains(mainType)) { + Set> 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("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("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"); + } +}