]> git.basschouten.com Git - openhab-addons.git/commitdiff
[echonetlite] Initial contribution (#11999)
authorMichael Barker <mikeb01@gmail.com>
Wed, 28 Sep 2022 21:23:57 +0000 (10:23 +1300)
committerGitHub <noreply@github.com>
Wed, 28 Sep 2022 21:23:57 +0000 (23:23 +0200)
* First implementation of Echonet Lite Java Bindings.  Only supports Mitsubishi Home Heat Pumps.

Signed-off-by: Michael Barker <mikeb01@gmail.com>
43 files changed:
CODEOWNERS
bom/openhab-addons/pom.xml
bundles/org.openhab.binding.echonetlite/NOTICE [new file with mode: 0644]
bundles/org.openhab.binding.echonetlite/README.md [new file with mode: 0644]
bundles/org.openhab.binding.echonetlite/pom.xml [new file with mode: 0644]
bundles/org.openhab.binding.echonetlite/src/main/feature/feature.xml [new file with mode: 0644]
bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetBridgeConfig.java [new file with mode: 0644]
bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetChannel.java [new file with mode: 0644]
bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetClass.java [new file with mode: 0644]
bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetClassIndex.java [new file with mode: 0644]
bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetDevice.java [new file with mode: 0644]
bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetDeviceConfig.java [new file with mode: 0644]
bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetDeviceListener.java [new file with mode: 0644]
bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetDiscoveryListener.java [new file with mode: 0644]
bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetDiscoveryService.java [new file with mode: 0644]
bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetLiteBindingConstants.java [new file with mode: 0644]
bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetLiteBridgeHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetLiteHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetLiteHandlerFactory.java [new file with mode: 0644]
bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetMessage.java [new file with mode: 0644]
bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetMessageBuilder.java [new file with mode: 0644]
bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetObject.java [new file with mode: 0644]
bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetProfileNode.java [new file with mode: 0644]
bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetPropertyMap.java [new file with mode: 0644]
bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/Epc.java [new file with mode: 0644]
bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EpcLookupTable.java [new file with mode: 0644]
bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/Esv.java [new file with mode: 0644]
bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/HexUtil.java [new file with mode: 0644]
bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/InstanceKey.java [new file with mode: 0644]
bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/LangUtil.java [new file with mode: 0644]
bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/MonotonicClock.java [new file with mode: 0644]
bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/ShortSupplier.java [new file with mode: 0644]
bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/StateCodec.java [new file with mode: 0644]
bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/StateDecode.java [new file with mode: 0644]
bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/StateEncode.java [new file with mode: 0644]
bundles/org.openhab.binding.echonetlite/src/main/resources/OH-INF/binding/binding.xml [new file with mode: 0644]
bundles/org.openhab.binding.echonetlite/src/main/resources/OH-INF/i18n/echonetlite.properties [new file with mode: 0644]
bundles/org.openhab.binding.echonetlite/src/main/resources/OH-INF/thing/channel-types.xml [new file with mode: 0644]
bundles/org.openhab.binding.echonetlite/src/main/resources/OH-INF/thing/thing-types.xml [new file with mode: 0644]
bundles/org.openhab.binding.echonetlite/src/test/java/org/openhab/binding/echonetlite/internal/EpcTest.java [new file with mode: 0644]
bundles/org.openhab.binding.echonetlite/src/test/java/org/openhab/binding/echonetlite/internal/protocol/LangUtilTest.java [new file with mode: 0644]
bundles/org.openhab.binding.echonetlite/src/test/java/org/openhab/binding/echonetlite/internal/protocol/StateCodecTest.java [new file with mode: 0644]
bundles/pom.xml

index 32edcbe2cda503b9c5232eafa556c13dd47271e5..cf6bc84ef17f95a61d215ab7aa905734a1af9f24 100644 (file)
@@ -77,6 +77,7 @@
 /bundles/org.openhab.binding.dsmr/ @Hilbrand
 /bundles/org.openhab.binding.dwdpollenflug/ @DerOetzi
 /bundles/org.openhab.binding.dwdunwetter/ @limdul79
+/bundles/org.openhab.binding.echonetlite/ @mikeb01
 /bundles/org.openhab.binding.ecobee/ @mhilbush
 /bundles/org.openhab.binding.ecotouch/ @sibbi77
 /bundles/org.openhab.binding.ecowatt/ @lolodomo
index 73ebdac2488e7fb11e80bb1ce4eab9ec45aad2dd..be5f6fa78569d8a5782bbb7c6e0dc1d880b6b7b7 100644 (file)
       <artifactId>org.openhab.binding.easee</artifactId>
       <version>${project.version}</version>
     </dependency>
+    <dependency>
+      <groupId>org.openhab.addons.bundles</groupId>
+      <artifactId>org.openhab.binding.echonetlite</artifactId>
+      <version>${project.version}</version>
+    </dependency>
     <dependency>
       <groupId>org.openhab.addons.bundles</groupId>
       <artifactId>org.openhab.binding.ecobee</artifactId>
diff --git a/bundles/org.openhab.binding.echonetlite/NOTICE b/bundles/org.openhab.binding.echonetlite/NOTICE
new file mode 100644 (file)
index 0000000..38d625e
--- /dev/null
@@ -0,0 +1,13 @@
+This content is produced and maintained by the openHAB project.
+
+* Project home: https://www.openhab.org
+
+== Declared Project Licenses
+
+This program and the accompanying materials are made available under the terms
+of the Eclipse Public License 2.0 which is available at
+https://www.eclipse.org/legal/epl-2.0/.
+
+== Source Code
+
+https://github.com/openhab/openhab-addons
diff --git a/bundles/org.openhab.binding.echonetlite/README.md b/bundles/org.openhab.binding.echonetlite/README.md
new file mode 100644 (file)
index 0000000..fd4da57
--- /dev/null
@@ -0,0 +1,87 @@
+# EchonetLite Binding
+
+This binding supports devices that make use of the Echonet Lite specification (https://echonet.jp/spec_v113_lite_en/).
+
+## Supported Things
+
+* Mitsubishi Electric MAC-568IF-E Wi-Fi interface (common on most Mitsubishi Heat Pumps).
+
+## Discovery
+
+Discovery is supported using UDP Multicast.
+When running over Wi-Fi it is advisable to run openHAB on the same network as the Echonet Lite devices.
+Multicast traffic doesn't easily route over multiple networks and will often be dropped.
+Discovery is handled via the Echonet Lite bridge, which contains the configuration of the multicast address used for discovery and asynchronous device notifications along with the port.
+It is unlikely that this configuration will require changing.
+
+## Bridge Configuration
+
+The bridge configuration defaults should be applicable in most scenarios.
+If device discovery is not working, this is most likely caused by the inability to receive multicast traffic from the device nodes.
+
+* __port__: Port used for messaging both to and from device nodes, defaults to 3610.
+* __multicastAddress__: Multicast address used to discover device nodes and to receive asynchronous notifications from devices.
+
+## Thing Configuration
+
+* __hostname__: Hostname or IP address of the device node.
+* __port__: Port used to communicate with the device. 
+* __groupCode__: Group code as specified in "APPENDIX Detailed Requirements for ECHONET Device objects" (https://echonet.jp/spec_object_rp1_en/).
+For Air Conditioners the value is '1'.
+* __classCode__: Class code for the device, see __groupCode__ for reference information.
+The value for Home Air Conditioners is '48' (0x30).
+* __instance__: Instance identifier if multiple instances are running on the same IP address.
+Typically, this value will be '1'.
+* __pollIntervalMs__: Interval between polls of the device for its current status.
+If multicast is not working this will determine the latency at which changes made directly on the device will be propagated back to openHAB, default is 30 000ms.
+* __retryTimeoutMs__: Length of time the bridge will wait before resubmitting a request, default is 2 000ms.
+
+Because the binding uses UDP, packets can be lost on the network, so retries are necessary.
+Testing has shown that 2 000ms is a reasonable default that allows for timely retries without rejecting slow, but legitimate responses.
+
+## Channels
+
+Channels are derived from the Echonet Lite specification and vary from device to device depending on capabilities.
+The full set of potential channels is available from "APPENDIX Detailed Requirements for ECHONET Device objects" (https://echonet.jp/spec_object_rp1_en/)
+
+The channels currently implemented are:
+
+| Channel                            | Data Type | Description                                                             |
+|------------------------------------|-----------|-------------------------------------------------------------------------|
+| operationStatus                    | Switch    | Switch On/Off the device                                                |
+| installationLocation               | String    | Installation location (option)                                          |
+| standardVersionInformation         | String    | Standard Version Information                                            |
+| identificationNumber               | String    | Unique id for device (used by auto discovery for the thingId)           |
+| manufacturerFaultCode              | String    | Manufacturer Fault Code                                                 |
+| faultStatus                        | Switch    | Fault Status                                                            |
+| faultDescription                   | String    | Fault Description                                                       |
+| manufacturerCode                   | String    | Manufacturer Code                                                       |
+| businessFacilityCode               | String    | Business Facility Code                                                  |
+| powerSavingOperationSetting        | Switch    | Controls whether the unit is in power saving operation or not           |
+| cumulativeOperatingTime            | Number    | Cumulative Operating Time                                               |
+| airFlowRate                        | String    | Air Flow Rate                                                           |
+| automaticControlOfAirFlowDirection | String    | The type of automatic control applied to the air flow direction, if any |
+| automaticSwingOfAirFlow            | String    | Automatic Swing Of Air Flow                                             |
+| airFlowDirectionVertical           | String    | Air Flow Direction Vertical                                             |
+| airFlowDirectionHorizontal         | String    | Air Flow Direction Horizontal                                           |
+| operationMode                      | String    | The current mode for the Home AC unit (heating, cooling, etc.)          |
+| setTemperature                     | Number    | Desired target room temperature                                         |
+| measuredRoomTemperature            | Number    | Measured Room Temperature                                               |
+| measuredOutdoorTemperature         | Number    | Measured Outdoor Temperature                                            |
+
+## Full Example
+
+
+### Things
+
+```
+Bridge echonetlite:bridge:1 [port="3610", multicastAddress="224.0.23.0"] {
+    Thing device HeatPump_Bedroom1 "HeatPump Bedroom 1" @ "Bedroom 1" [hostname="192.168.0.55", port="3610", groupCode="1", classCode="48", instance="1", pollIntervalMs="30000", retryTimeoutMs="2000"]
+}
+```
+
+### Items
+
+```
+Switch HeatPumpBedroom1_OperationStatus "HeatPump Bedroom1 Operation Status" {channel="echonetlite:device:1:HeatPump_Bedroom1:operationStatus"}
+```
diff --git a/bundles/org.openhab.binding.echonetlite/pom.xml b/bundles/org.openhab.binding.echonetlite/pom.xml
new file mode 100644 (file)
index 0000000..20a4db3
--- /dev/null
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
+  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+
+  <modelVersion>4.0.0</modelVersion>
+
+  <parent>
+    <groupId>org.openhab.addons.bundles</groupId>
+    <artifactId>org.openhab.addons.reactor.bundles</artifactId>
+    <version>3.4.0-SNAPSHOT</version>
+  </parent>
+
+  <artifactId>org.openhab.binding.echonetlite</artifactId>
+
+  <name>openHAB Add-ons :: Bundles :: EchonetLite Binding</name>
+
+</project>
diff --git a/bundles/org.openhab.binding.echonetlite/src/main/feature/feature.xml b/bundles/org.openhab.binding.echonetlite/src/main/feature/feature.xml
new file mode 100644 (file)
index 0000000..9510220
--- /dev/null
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<features name="org.openhab.binding.echonetlite-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
+       <repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
+
+       <feature name="openhab-binding-echonetlite" description="EchonetLite Binding" version="${project.version}">
+               <feature>openhab-runtime-base</feature>
+               <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.echonetlite/${project.version}</bundle>
+       </feature>
+</features>
diff --git a/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetBridgeConfig.java b/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetBridgeConfig.java
new file mode 100644 (file)
index 0000000..ad916b7
--- /dev/null
@@ -0,0 +1,34 @@
+/**
+ * Copyright (c) 2010-2022 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.echonetlite.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * The {@link EchonetBridgeConfig} class contains fields mapping thing configuration parameters.
+ *
+ * @author Michael Barker - Initial contribution
+ */
+@NonNullByDefault
+public class EchonetBridgeConfig {
+
+    @Nullable
+    public String multicastAddress;
+    public int port;
+
+    @Override
+    public String toString() {
+        return "EchonetBridgeConfig{" + "multicastAddress='" + multicastAddress + '\'' + ", port=" + port + '}';
+    }
+}
diff --git a/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetChannel.java b/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetChannel.java
new file mode 100644 (file)
index 0000000..51d08c4
--- /dev/null
@@ -0,0 +1,106 @@
+/**
+ * Copyright (c) 2010-2022 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.echonetlite.internal;
+
+import java.io.IOException;
+import java.net.Inet4Address;
+import java.net.InetSocketAddress;
+import java.net.NetworkInterface;
+import java.net.SocketAddress;
+import java.net.StandardProtocolFamily;
+import java.nio.ByteBuffer;
+import java.nio.channels.DatagramChannel;
+import java.nio.channels.SelectionKey;
+import java.nio.channels.Selector;
+import java.util.Enumeration;
+import java.util.function.BiConsumer;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Wraps a Datagram channel for sending/receiving data to/from echonet lite devices.
+ *
+ * @author Michael Barker - Initial contribution
+ */
+@NonNullByDefault
+public class EchonetChannel {
+
+    private final Logger logger = LoggerFactory.getLogger(EchonetChannel.class);
+
+    private final DatagramChannel channel;
+    private final Selector selector = Selector.open();
+
+    private short tid = 0;
+
+    public EchonetChannel(InetSocketAddress discoveryAddress) throws IOException {
+        channel = DatagramChannel.open(StandardProtocolFamily.INET);
+        channel.bind(new InetSocketAddress("0.0.0.0", discoveryAddress.getPort()));
+        final Enumeration<NetworkInterface> networkInterfaces = NetworkInterface.getNetworkInterfaces();
+        while (networkInterfaces.hasMoreElements()) {
+            final NetworkInterface networkInterface = (NetworkInterface) networkInterfaces.nextElement();
+            if (networkInterface.supportsMulticast() && hasIpV4Address(networkInterface)) {
+                channel.join(discoveryAddress.getAddress(), networkInterface);
+            }
+        }
+        channel.configureBlocking(false);
+        channel.register(selector, SelectionKey.OP_READ);
+    }
+
+    private boolean hasIpV4Address(final NetworkInterface networkInterface) {
+        return networkInterface.inetAddresses().anyMatch(ia -> ia instanceof Inet4Address);
+    }
+
+    public void close() {
+        try {
+            logger.debug("closing selector");
+            selector.close();
+            logger.debug("closing channel");
+            channel.close();
+        } catch (IOException ignore) {
+        }
+    }
+
+    short nextTid() {
+        return tid++;
+    }
+
+    public void sendMessage(EchonetMessageBuilder messageBuilder) throws IOException {
+        messageBuilder.buffer().flip();
+        channel.send(messageBuilder.buffer(), messageBuilder.address());
+    }
+
+    public void pollMessages(EchonetMessage echonetMessage, BiConsumer<EchonetMessage, SocketAddress> consumer,
+            final long timeout) throws IOException {
+        selector.select(selectionKey -> {
+            final DatagramChannel channel = (DatagramChannel) selectionKey.channel();
+            try {
+                final ByteBuffer buffer = echonetMessage.bufferForRead();
+                final SocketAddress address = channel.receive(buffer);
+
+                echonetMessage.sourceAddress(address);
+                buffer.flip();
+                long t0 = System.currentTimeMillis();
+                consumer.accept(echonetMessage, address);
+                long t1 = System.currentTimeMillis();
+                final long processingTimeMs = t1 - t0;
+                if (500 < processingTimeMs) {
+                    logger.debug("Message took {}ms to process", processingTimeMs);
+                }
+            } catch (IOException e) {
+                logger.warn("Failed to receive on channel", e);
+            }
+        }, timeout);
+    }
+}
diff --git a/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetClass.java b/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetClass.java
new file mode 100644 (file)
index 0000000..5a4e4dd
--- /dev/null
@@ -0,0 +1,77 @@
+/**
+ * Copyright (c) 2010-2022 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.echonetlite.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * @author Michael Barker - Initial contribution
+ */
+@NonNullByDefault
+public enum EchonetClass {
+    AIRCON_HOMEAC(0x01, 0x30, (Epc[]) Epc.Device.values(), (Epc[]) Epc.AcGroup.values(), (Epc[]) Epc.HomeAc.values()),
+    MANAGEMENT_CONTROLLER(0x05, 0xFF, new Epc[0], new Epc[0], new Epc[0]),
+    NODE_PROFILE(0x0e, 0xf0, (Epc[]) Epc.Profile.values(), (Epc[]) Epc.ProfileGroup.values(),
+            (Epc[]) Epc.NodeProfile.values());
+
+    private final int groupCode;
+    private final int classCode;
+    private final Epc[] deviceProperties;
+    private final Epc[] groupProperties;
+    private final Epc[] classProperties;
+
+    EchonetClass(final int groupCode, final int classCode, Epc[] deviceProperties, Epc[] groupProperties,
+            Epc[] classProperties) {
+        this.groupCode = groupCode;
+        this.classCode = classCode;
+        this.deviceProperties = deviceProperties;
+        this.groupProperties = groupProperties;
+        this.classProperties = classProperties;
+    }
+
+    public static EchonetClass resolve(final int groupCode, final int classCode) {
+        final EchonetClass[] values = values();
+        for (EchonetClass value : values) {
+            if (value.groupCode == groupCode && value.classCode == classCode) {
+                return value;
+            }
+        }
+
+        throw new IllegalArgumentException("Unable to find class: " + groupCode + "/" + classCode);
+    }
+
+    public int groupCode() {
+        return groupCode;
+    }
+
+    public int classCode() {
+        return classCode;
+    }
+
+    Epc[] deviceProperties() {
+        return deviceProperties;
+    }
+
+    Epc[] groupProperties() {
+        return groupProperties;
+    }
+
+    Epc[] classProperties() {
+        return classProperties;
+    }
+
+    public String toString() {
+        return name() + "{" + "groupCode=0x" + Integer.toHexString(groupCode) + ", classCode=0x"
+                + Integer.toHexString(0xFF & classCode) + '}';
+    }
+}
diff --git a/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetClassIndex.java b/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetClassIndex.java
new file mode 100644 (file)
index 0000000..95c48f6
--- /dev/null
@@ -0,0 +1,39 @@
+/**
+ * Copyright (c) 2010-2022 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.echonetlite.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * @author Michael Barker - Initial contribution
+ */
+@NonNullByDefault
+public enum EchonetClassIndex {
+    INSTANCE;
+
+    private static final EchonetClass[] INDEX = new EchonetClass[1 << 16];
+    static {
+        final EchonetClass[] values = EchonetClass.values();
+        for (final EchonetClass value : values) {
+            INDEX[codeToIndex(value.groupCode(), value.classCode())] = value;
+        }
+    }
+
+    public static int codeToIndex(final int groupCode, final int classCode) {
+        return ((0xFF & groupCode) << 8) + (0xFF & classCode);
+    }
+
+    public EchonetClass lookup(final int groupCode, final int classCode) {
+        return INDEX[codeToIndex(groupCode, classCode)];
+    }
+}
diff --git a/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetDevice.java b/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetDevice.java
new file mode 100644 (file)
index 0000000..7dcf22e
--- /dev/null
@@ -0,0 +1,211 @@
+/**
+ * Copyright (c) 2010-2022 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.echonetlite.internal;
+
+import static org.openhab.binding.echonetlite.internal.HexUtil.hex;
+
+import java.nio.ByteBuffer;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.types.State;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * @author Michael Barker - Initial contribution
+ */
+@NonNullByDefault
+public class EchonetDevice extends EchonetObject {
+
+    private final LinkedHashMap<Epc, State> pendingSets = new LinkedHashMap<>();
+    private final HashMap<Epc, State> stateFields = new HashMap<>();
+    private final HashMap<String, Epc> epcByChannelId = new HashMap<>();
+    private final Logger logger = LoggerFactory.getLogger(EchonetDevice.class);
+    @Nullable
+    private EchonetPropertyMap getPropertyMap;
+    private EchonetDeviceListener listener;
+    private boolean initialised = false;
+
+    private long lastPollMs = 0;
+
+    public EchonetDevice(final InstanceKey instanceKey, EchonetDeviceListener listener) {
+        super(instanceKey, Epc.Device.GET_PROPERTY_MAP);
+        this.listener = listener;
+    }
+
+    public void applyProperty(InstanceKey sourceInstanceKey, Esv esv, final int epcCode, final int pdc,
+            final ByteBuffer edt) {
+        final Epc epc = Epc.lookup(instanceKey().klass.groupCode(), instanceKey().klass.classCode(), epcCode);
+
+        if ((Esv.Get_Res == esv || Esv.Get_SNA == esv || Esv.INF == esv) && 0 < pdc) {
+            pendingGets.remove(epc);
+
+            int edtPosition = edt.position();
+
+            final StateDecode decoder = epc.decoder();
+            State state = null;
+            if (null != decoder) {
+                state = decoder.decodeState(edt);
+                if (null == stateFields.put(epc, state)) {
+                    epcByChannelId.put(epc.channelId(), epc);
+                }
+
+                final @Nullable State pendingState = lookupPendingSet(epc);
+                if (null != pendingState && pendingState.equals(state)) {
+                    logger.debug("pendingSet - removing: {} {}", epc, state);
+                    pendingSets.remove(epc);
+                } else if (null != pendingState) {
+                    logger.debug("pendingSet - state mismatch: {} {} {}", epc, pendingState, state);
+                }
+
+                if (initialised) {
+                    listener.onUpdated(epc.channelId(), state);
+                }
+            } else if (Epc.Device.GET_PROPERTY_MAP == epc) {
+                if (null == getPropertyMap) {
+                    final EchonetPropertyMap getPropertyMap = new EchonetPropertyMap(epc);
+                    getPropertyMap.update(edt);
+                    getPropertyMap.getProperties(instanceKey().klass.groupCode(), instanceKey().klass.classCode(),
+                            Set.of(Epc.Device.GET_PROPERTY_MAP), pendingGets);
+                    this.getPropertyMap = getPropertyMap;
+                }
+            }
+
+            if (!initialised && null != getPropertyMap && pendingGets.isEmpty()) {
+                initialised = true;
+                listener.onInitialised(identifier(), instanceKey, channelIds());
+                stateFields.forEach((e, s) -> listener.onUpdated(e.channelId(), s));
+            }
+
+            if (logger.isDebugEnabled()) {
+                String value = null != state ? state.toString() : "";
+                edt.position(edtPosition);
+                logger.debug("Applying: {}({},{}) {} {} pending: {}", epc, hex(epc.code()), pdc, value, hex(edt),
+                        pendingGets.size());
+            }
+        } else if (esv == Esv.Set_Res) {
+            pendingSets.remove(epc);
+        }
+    }
+
+    public String identifier() {
+        final State identificationNumber = stateFields.get(Epc.Device.IDENTIFICATION_NUMBER);
+        if (null == identificationNumber) {
+            throw new IllegalStateException("Echonet devices must support identification number property");
+        }
+
+        return identificationNumber.toString();
+    }
+
+    public boolean buildUpdateMessage(final EchonetMessageBuilder messageBuilder, final ShortSupplier tidSupplier,
+            final long nowMs, InstanceKey managementControllerKey) {
+        if (pendingSets.isEmpty()) {
+            return false;
+        }
+
+        final InflightRequest inflightSetRequest = this.inflightSetRequest;
+
+        if (hasInflight(nowMs, inflightSetRequest)) {
+            return false;
+        }
+
+        final short tid = tidSupplier.getAsShort();
+        messageBuilder.start(tid, managementControllerKey, instanceKey, Esv.SetC);
+
+        pendingSets.forEach((k, v) -> {
+            final StateEncode encoder = k.encoder();
+            if (null != encoder) {
+                final ByteBuffer buffer = messageBuilder.edtBuffer();
+                encoder.encodeState(v, buffer);
+                messageBuilder.appendEpcUpdate(k.code(), buffer.flip());
+            }
+        });
+
+        inflightSetRequest.requestSent(tid, nowMs);
+
+        return true;
+    }
+
+    public void update(String channelId, State state) {
+        final Epc epc = epcByChannelId.get(channelId);
+        if (null == epc) {
+            logger.warn("Unable to find epc for channelId: {}", channelId);
+            return;
+        }
+
+        pendingSets.put(epc, state);
+    }
+
+    @Override
+    public void removed() {
+        listener.onRemoved();
+    }
+
+    public void checkTimeouts() {
+        if (EchonetLiteBindingConstants.OFFLINE_TIMEOUT_COUNT <= inflightGetRequest.timeoutCount()) {
+            listener.onOffline();
+        }
+    }
+
+    public void refreshAll(long nowMs) {
+        final EchonetPropertyMap getPropertyMap = this.getPropertyMap;
+        if (lastPollMs + pollIntervalMs <= nowMs && null != getPropertyMap) {
+            getPropertyMap.getProperties(instanceKey().klass.groupCode(), instanceKey().klass.classCode(),
+                    Set.of(Epc.Device.GET_PROPERTY_MAP), pendingGets);
+            lastPollMs = nowMs;
+        }
+    }
+
+    @Override
+    public void refresh(String channelId) {
+        final Epc epc = epcByChannelId.get(channelId);
+        if (null == epc) {
+            return;
+        }
+
+        final State state = stateFields.get(epc);
+        if (null == state) {
+            return;
+        }
+
+        listener.onUpdated(channelId, state);
+    }
+
+    public void setListener(EchonetDeviceListener listener) {
+        this.listener = listener;
+        if (initialised) {
+            listener.onInitialised(identifier(), instanceKey(), channelIds());
+            stateFields.forEach((e, s) -> listener.onUpdated(e.channelId(), s));
+        }
+    }
+
+    private Map<String, String> channelIds() {
+        final HashMap<String, String> channelIdAndType = new HashMap<>();
+        for (Epc e : stateFields.keySet()) {
+            final StateDecode decoder = e.decoder();
+            if (null != decoder) {
+                channelIdAndType.put(e.channelId(), decoder.itemType());
+            }
+        }
+        return channelIdAndType;
+    }
+
+    private @Nullable State lookupPendingSet(Epc epc) {
+        return pendingSets.get(epc);
+    }
+}
diff --git a/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetDeviceConfig.java b/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetDeviceConfig.java
new file mode 100644 (file)
index 0000000..2657ff4
--- /dev/null
@@ -0,0 +1,44 @@
+/**
+ * Copyright (c) 2010-2022 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.echonetlite.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * The {@link EchonetDeviceConfig} class contains fields mapping thing configuration parameters.
+ *
+ * @author Michael Barker - Initial contribution
+ */
+@NonNullByDefault
+public class EchonetDeviceConfig {
+
+    /**
+     * Sample configuration parameters. Replace with your own.
+     */
+    @Nullable
+    public String hostname;
+    public int port;
+    public int groupCode;
+    public int classCode;
+    public int instance;
+    public long pollIntervalMs;
+    public long retryTimeoutMs;
+
+    @Override
+    public String toString() {
+        return "EchonetLiteConfiguration{" + "hostname='" + hostname + '\'' + ", port=" + port + ", groupCode="
+                + groupCode + ", classCode=" + classCode + ", instance=" + instance + ", pollIntervalMs="
+                + pollIntervalMs + ", retryTimeoutMs=" + retryTimeoutMs + '}';
+    }
+}
diff --git a/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetDeviceListener.java b/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetDeviceListener.java
new file mode 100644 (file)
index 0000000..1d6fb43
--- /dev/null
@@ -0,0 +1,36 @@
+/**
+ * Copyright (c) 2010-2022 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.echonetlite.internal;
+
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.types.State;
+
+/**
+ * @author Michael Barker - Initial contribution
+ */
+@NonNullByDefault
+public interface EchonetDeviceListener {
+    default void onInitialised(String identifier, InstanceKey instanceKey, Map<String, String> channelIdAndType) {
+    }
+
+    default void onUpdated(String channelId, State value) {
+    }
+
+    default void onRemoved() {
+    }
+
+    default void onOffline() {
+    }
+}
diff --git a/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetDiscoveryListener.java b/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetDiscoveryListener.java
new file mode 100644 (file)
index 0000000..3769bbe
--- /dev/null
@@ -0,0 +1,23 @@
+/**
+ * Copyright (c) 2010-2022 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.echonetlite.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * @author Michael Barker - Initial contribution
+ */
+@NonNullByDefault
+public interface EchonetDiscoveryListener {
+    void onDeviceFound(String identifier, InstanceKey instanceKey);
+}
diff --git a/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetDiscoveryService.java b/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetDiscoveryService.java
new file mode 100644 (file)
index 0000000..a7692f1
--- /dev/null
@@ -0,0 +1,112 @@
+/**
+ * Copyright (c) 2010-2022 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.echonetlite.internal;
+
+import static org.openhab.binding.echonetlite.internal.EchonetLiteBindingConstants.PROPERTY_NAME_CLASS_CODE;
+import static org.openhab.binding.echonetlite.internal.EchonetLiteBindingConstants.PROPERTY_NAME_GROUP_CODE;
+import static org.openhab.binding.echonetlite.internal.EchonetLiteBindingConstants.PROPERTY_NAME_HOSTNAME;
+import static org.openhab.binding.echonetlite.internal.EchonetLiteBindingConstants.PROPERTY_NAME_INSTANCE;
+import static org.openhab.binding.echonetlite.internal.EchonetLiteBindingConstants.PROPERTY_NAME_INSTANCE_KEY;
+import static org.openhab.binding.echonetlite.internal.EchonetLiteBindingConstants.PROPERTY_NAME_PORT;
+import static org.openhab.binding.echonetlite.internal.EchonetLiteBindingConstants.THING_TYPE_ECHONET_DEVICE;
+
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.config.discovery.AbstractDiscoveryService;
+import org.openhab.core.config.discovery.DiscoveryResult;
+import org.openhab.core.config.discovery.DiscoveryResultBuilder;
+import org.openhab.core.thing.ThingUID;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.openhab.core.thing.binding.ThingHandlerService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * @author Michael Barker - Initial contribution
+ */
+@NonNullByDefault
+public class EchonetDiscoveryService extends AbstractDiscoveryService
+        implements EchonetDiscoveryListener, ThingHandlerService {
+
+    private final Logger logger = LoggerFactory.getLogger(EchonetDiscoveryService.class);
+
+    @Nullable
+    private EchonetLiteBridgeHandler bridgeHandler;
+
+    public EchonetDiscoveryService() {
+        super(Set.of(THING_TYPE_ECHONET_DEVICE), 10);
+    }
+
+    @Override
+    protected void startScan() {
+        final EchonetLiteBridgeHandler bridgeHandler = this.bridgeHandler;
+        logger.debug("startScan: {}", bridgeHandler);
+        if (null != bridgeHandler) {
+            bridgeHandler.startDiscovery(this);
+        }
+    }
+
+    @Override
+    protected synchronized void stopScan() {
+        final EchonetLiteBridgeHandler bridgeHandler = this.bridgeHandler;
+        logger.debug("stopScan: {}", bridgeHandler);
+        if (null != bridgeHandler) {
+            bridgeHandler.stopDiscovery();
+        }
+    }
+
+    @Override
+    public void onDeviceFound(String identifier, InstanceKey instanceKey) {
+        final EchonetLiteBridgeHandler bridgeHandler = this.bridgeHandler;
+
+        if (null == bridgeHandler) {
+            return;
+        }
+
+        final DiscoveryResult discoveryResult = DiscoveryResultBuilder
+                .create(new ThingUID(THING_TYPE_ECHONET_DEVICE, bridgeHandler.getThing().getUID(), identifier))
+                .withProperty(PROPERTY_NAME_INSTANCE_KEY, instanceKey.representationProperty())
+                .withProperty(PROPERTY_NAME_HOSTNAME, instanceKey.address.getAddress().getHostAddress())
+                .withProperty(PROPERTY_NAME_PORT, instanceKey.address.getPort())
+                .withProperty(PROPERTY_NAME_GROUP_CODE, instanceKey.klass.groupCode())
+                .withProperty(PROPERTY_NAME_CLASS_CODE, instanceKey.klass.classCode())
+                .withProperty(PROPERTY_NAME_INSTANCE, instanceKey.instance)
+                .withBridge(bridgeHandler.getThing().getUID()).withRepresentationProperty(PROPERTY_NAME_INSTANCE_KEY)
+                .build();
+        thingDiscovered(discoveryResult);
+    }
+
+    @Override
+    public void deactivate() {
+        ThingHandlerService.super.deactivate();
+    }
+
+    @Override
+    public void activate() {
+        ThingHandlerService.super.activate();
+    }
+
+    @Override
+    public void setThingHandler(ThingHandler thingHandler) {
+        if (thingHandler instanceof EchonetLiteBridgeHandler) {
+            this.bridgeHandler = (EchonetLiteBridgeHandler) thingHandler;
+        }
+    }
+
+    @Override
+    public @Nullable ThingHandler getThingHandler() {
+        return bridgeHandler;
+    }
+}
diff --git a/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetLiteBindingConstants.java b/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetLiteBindingConstants.java
new file mode 100644 (file)
index 0000000..9060600
--- /dev/null
@@ -0,0 +1,46 @@
+/**
+ * Copyright (c) 2010-2022 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.echonetlite.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.thing.ThingTypeUID;
+
+/**
+ * The {@link EchonetLiteBindingConstants} class defines common constants, which are
+ * used across the whole binding.
+ *
+ * @author Michael Barker - Initial contribution
+ */
+@NonNullByDefault
+public class EchonetLiteBindingConstants {
+
+    public static final long DEFAULT_POLL_INTERVAL_MS = 30_000;
+    public static final long DEFAULT_RETRY_TIMEOUT_MS = 2_000;
+    public static final int NETWORK_WAIT_TIMEOUT = 250;
+
+    // List of all Thing Type UIDs
+    public static final String BINDING_ID = "echonetlite";
+    public static final ThingTypeUID THING_TYPE_ECHONET_DEVICE = new ThingTypeUID(BINDING_ID, "device");
+    public static final ThingTypeUID THING_TYPE_ECHONET_BRIDGE = new ThingTypeUID(BINDING_ID, "bridge");
+
+    public static final StateCodec.OnOffCodec ON_OFF_CODEC_30_31 = new StateCodec.OnOffCodec(0x30, 0x31);
+    public static final StateCodec.OnOffCodec ON_OFF_CODEC_41_42 = new StateCodec.OnOffCodec(0x41, 0x42);
+
+    public static final String PROPERTY_NAME_INSTANCE_KEY = "instanceKey";
+    public static final String PROPERTY_NAME_HOSTNAME = "hostname";
+    public static final String PROPERTY_NAME_PORT = "port";
+    public static final String PROPERTY_NAME_GROUP_CODE = "groupCode";
+    public static final String PROPERTY_NAME_CLASS_CODE = "classCode";
+    public static final String PROPERTY_NAME_INSTANCE = "instance";
+    public static final int OFFLINE_TIMEOUT_COUNT = 2;
+}
diff --git a/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetLiteBridgeHandler.java b/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetLiteBridgeHandler.java
new file mode 100644 (file)
index 0000000..4e6f036
--- /dev/null
@@ -0,0 +1,398 @@
+/**
+ * Copyright (c) 2010-2022 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.echonetlite.internal;
+
+import static java.util.Objects.requireNonNull;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.net.SocketAddress;
+import java.nio.ByteBuffer;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.binding.BaseBridgeHandler;
+import org.openhab.core.thing.binding.ThingHandlerService;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Bridge handler for echonet lite devices. By default, all messages (inbound and outbound) happen on port 3610, so
+ * we can only have a single listener for echonet lite messages. Hence, using a bridge model to handle communications
+ * and discovery.
+ *
+ * @author Michael Barker - Initial contribution
+ */
+@NonNullByDefault
+public class EchonetLiteBridgeHandler extends BaseBridgeHandler {
+
+    private final Logger logger = LoggerFactory.getLogger(EchonetLiteBridgeHandler.class);
+    private final ArrayBlockingQueue<Message> requests = new ArrayBlockingQueue<>(1024);
+    private final Map<InstanceKey, EchonetObject> devicesByKey = new HashMap<>();
+    private final EchonetMessageBuilder messageBuilder = new EchonetMessageBuilder();
+    private final Thread networkingThread = new Thread(this::poll);
+    private final EchonetMessage echonetMessage = new EchonetMessage();
+    private final MonotonicClock clock = new MonotonicClock();
+
+    @Nullable
+    private EchonetChannel echonetChannel;
+
+    @Nullable
+    private InstanceKey managementControllerKey;
+
+    @Nullable
+    private InstanceKey discoveryKey;
+
+    public EchonetLiteBridgeHandler(Bridge bridge) {
+        super(bridge);
+    }
+
+    private void start(final InstanceKey managementControllerKey, InstanceKey discoveryKey) throws IOException {
+        this.managementControllerKey = managementControllerKey;
+        this.discoveryKey = discoveryKey;
+
+        logger.debug("Binding echonet channel");
+        echonetChannel = new EchonetChannel(discoveryKey.address);
+        logger.debug("Starting networking thread");
+
+        networkingThread.setName("OH-binding-" + EchonetLiteBindingConstants.BINDING_ID);
+        networkingThread.setDaemon(true);
+        networkingThread.start();
+    }
+
+    public void newDevice(InstanceKey instanceKey, long pollIntervalMs, long retryTimeoutMs,
+            final EchonetDeviceListener echonetDeviceListener) {
+        requests.add(new NewDeviceMessage(instanceKey, pollIntervalMs, retryTimeoutMs, echonetDeviceListener));
+    }
+
+    private void newDeviceInternal(final NewDeviceMessage message) {
+        final EchonetObject echonetObject = devicesByKey.get(message.instanceKey);
+        if (null != echonetObject) {
+            if (echonetObject instanceof EchonetDevice) {
+                logger.debug("Update item: {} already discovered", message.instanceKey);
+                EchonetDevice device = (EchonetDevice) echonetObject;
+                device.setTimeouts(message.pollIntervalMs, message.retryTimeoutMs);
+                device.setListener(message.echonetDeviceListener);
+            } else {
+                logger.debug("Item: {} already discovered, but was not a device", message.instanceKey);
+            }
+        } else {
+            logger.debug("New Device: {}", message.instanceKey);
+            final EchonetDevice device = new EchonetDevice(message.instanceKey, message.echonetDeviceListener);
+            device.setTimeouts(message.pollIntervalMs, message.retryTimeoutMs);
+            devicesByKey.put(message.instanceKey, device);
+        }
+    }
+
+    public void refreshDevice(final InstanceKey instanceKey, final String channelId) {
+        requests.add(new RefreshMessage(instanceKey, channelId));
+    }
+
+    private void refreshDeviceInternal(final RefreshMessage refreshMessage) {
+        final EchonetObject item = devicesByKey.get(refreshMessage.instanceKey);
+        if (null != item) {
+            item.refresh(refreshMessage.channelId);
+        }
+    }
+
+    public void removeDevice(final InstanceKey instanceKey) {
+        requests.add(new RemoveDevice(instanceKey));
+    }
+
+    private void removeDeviceInternal(final RemoveDevice removeDevice) {
+        final EchonetObject remove = devicesByKey.remove(removeDevice.instanceKey);
+
+        logger.debug("Removing device: {}, {}", removeDevice.instanceKey, remove);
+        if (null != remove) {
+            remove.removed();
+        }
+    }
+
+    public void updateDevice(final InstanceKey instanceKey, final String id, final State command) {
+        requests.add(new UpdateDevice(instanceKey, id, command));
+    }
+
+    public void updateDeviceInternal(UpdateDevice updateDevice) {
+        final EchonetObject echonetObject = devicesByKey.get(updateDevice.instanceKey);
+
+        if (null == echonetObject) {
+            logger.warn("Device not found for update: {}", updateDevice);
+            return;
+        }
+
+        echonetObject.update(updateDevice.channelId, updateDevice.state);
+    }
+
+    public void startDiscovery(EchonetDiscoveryListener echonetDiscoveryListener) {
+        requests.offer(new StartDiscoveryMessage(echonetDiscoveryListener, requireNonNull(discoveryKey)));
+    }
+
+    public void startDiscoveryInternal(StartDiscoveryMessage startDiscovery) {
+        devicesByKey.put(startDiscovery.instanceKey, new EchonetProfileNode(startDiscovery.instanceKey,
+                this::onDiscoveredInstanceKey, startDiscovery.echonetDiscoveryListener));
+    }
+
+    public void stopDiscovery() {
+        requests.offer(new StopDiscoveryMessage(requireNonNull(discoveryKey)));
+    }
+
+    private void stopDiscoveryInternal(StopDiscoveryMessage stopDiscovery) {
+        devicesByKey.remove(stopDiscovery.instanceKey);
+    }
+
+    private void onDiscoveredInstanceKey(EchonetDevice device) {
+        if (null == devicesByKey.putIfAbsent(device.instanceKey(), device)) {
+            logger.debug("New device discovered: {}", device.instanceKey);
+        }
+    }
+
+    private void pollDevices(long nowMs, EchonetChannel echonetChannel) {
+        for (EchonetObject echonetObject : devicesByKey.values()) {
+            if (echonetObject.buildUpdateMessage(messageBuilder, echonetChannel::nextTid, nowMs,
+                    requireNonNull(managementControllerKey))) {
+                try {
+                    echonetChannel.sendMessage(messageBuilder);
+                } catch (IOException e) {
+                    logger.warn("Failed to send echonet message", e);
+                }
+            }
+
+            echonetObject.refreshAll(nowMs);
+
+            if (echonetObject.buildPollMessage(messageBuilder, echonetChannel::nextTid, nowMs,
+                    requireNonNull(managementControllerKey))) {
+                try {
+                    echonetChannel.sendMessage(messageBuilder);
+                } catch (IOException e) {
+                    logger.warn("Failed to send echonet message", e);
+                }
+            } else {
+                echonetObject.checkTimeouts();
+            }
+        }
+    }
+
+    private void pollRequests() {
+        Message message;
+        while (null != (message = requestsPoll())) {
+            logger.debug("Received request: {}", message);
+            if (message instanceof NewDeviceMessage) {
+                newDeviceInternal((NewDeviceMessage) message);
+            } else if (message instanceof RefreshMessage) {
+                refreshDeviceInternal((RefreshMessage) message);
+            } else if (message instanceof RemoveDevice) {
+                removeDeviceInternal((RemoveDevice) message);
+            } else if (message instanceof UpdateDevice) {
+                updateDeviceInternal((UpdateDevice) message);
+            } else if (message instanceof StartDiscoveryMessage) {
+                startDiscoveryInternal((StartDiscoveryMessage) message);
+            } else if (message instanceof StopDiscoveryMessage) {
+                stopDiscoveryInternal((StopDiscoveryMessage) message);
+            }
+        }
+    }
+
+    private @Nullable Message requestsPoll() {
+        return requests.poll();
+    }
+
+    private void pollNetwork(EchonetChannel echonetChannel) {
+        try {
+            echonetChannel.pollMessages(echonetMessage, this::onMessage,
+                    EchonetLiteBindingConstants.NETWORK_WAIT_TIMEOUT);
+        } catch (IOException e) {
+            logger.warn("Failed to poll for messages", e);
+        }
+    }
+
+    private void onMessage(final EchonetMessage echonetMessage, final SocketAddress sourceAddress) {
+        final EchonetClass echonetClass = echonetMessage.sourceClass();
+        if (null == echonetClass) {
+            logger.warn("Unable to find echonetClass for message: {}, from: {}", echonetMessage.toDebug(),
+                    sourceAddress);
+            return;
+        }
+
+        final InstanceKey instanceKey = new InstanceKey((InetSocketAddress) sourceAddress, echonetClass,
+                echonetMessage.instance());
+        final Esv esv = echonetMessage.esv();
+
+        EchonetObject echonetObject = devicesByKey.get(instanceKey);
+        if (null == echonetObject) {
+            echonetObject = devicesByKey.get(discoveryKey);
+        }
+
+        logger.debug("Message {} for: {}", esv, echonetObject);
+        if (null != echonetObject) {
+            echonetObject.applyHeader(esv, echonetMessage.tid(), clock.timeMs());
+            while (echonetMessage.moveNext()) {
+                final int epc = echonetMessage.currentEpc();
+                final int pdc = echonetMessage.currentPdc();
+                ByteBuffer edt = echonetMessage.currentEdt();
+                echonetObject.applyProperty(instanceKey, esv, epc, pdc, edt);
+            }
+        }
+    }
+
+    private void poll() {
+        try {
+            doPoll();
+            updateStatus(ThingStatus.ONLINE);
+
+            while (!Thread.currentThread().isInterrupted()) {
+                doPoll();
+            }
+        } catch (Exception e) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
+        }
+    }
+
+    private void doPoll() {
+        final long nowMs = clock.timeMs();
+        pollRequests();
+        pollDevices(nowMs, requireNonNull(echonetChannel));
+        pollNetwork(requireNonNull(echonetChannel));
+    }
+
+    @Override
+    public void initialize() {
+        final EchonetBridgeConfig bridgeConfig = getConfigAs(EchonetBridgeConfig.class);
+
+        final InstanceKey managementControllerKey = new InstanceKey(new InetSocketAddress(bridgeConfig.port),
+                EchonetClass.MANAGEMENT_CONTROLLER, (byte) 0x01);
+        final InstanceKey discoveryKey = new InstanceKey(
+                new InetSocketAddress(requireNonNull(bridgeConfig.multicastAddress), bridgeConfig.port),
+                EchonetClass.NODE_PROFILE, (byte) 0x01);
+
+        updateStatus(ThingStatus.UNKNOWN);
+
+        try {
+            start(managementControllerKey, discoveryKey);
+        } catch (IOException e) {
+            throw new IllegalStateException("Unable to start networking thread", e);
+        }
+    }
+
+    @Override
+    public void dispose() {
+        if (networkingThread.isAlive()) {
+            networkingThread.interrupt();
+            try {
+                networkingThread.join(TimeUnit.SECONDS.toMillis(5));
+            } catch (InterruptedException e) {
+                logger.debug("Interrupted while closing", e);
+            }
+        }
+
+        @Nullable
+        final EchonetChannel echonetChannel = this.echonetChannel;
+        if (null != echonetChannel) {
+            echonetChannel.close();
+        }
+    }
+
+    @Override
+    public void handleCommand(ChannelUID channelUID, Command command) {
+    }
+
+    @Override
+    public Collection<Class<? extends ThingHandlerService>> getServices() {
+        return Collections.singletonList(EchonetDiscoveryService.class);
+    }
+
+    private abstract static class Message {
+        final InstanceKey instanceKey;
+
+        public Message(InstanceKey instanceKey) {
+            this.instanceKey = instanceKey;
+        }
+    }
+
+    private static final class NewDeviceMessage extends Message {
+        final long pollIntervalMs;
+        final long retryTimeoutMs;
+        final EchonetDeviceListener echonetDeviceListener;
+
+        public NewDeviceMessage(final InstanceKey instanceKey, long pollIntervalMs, long retryTimeoutMs,
+                final EchonetDeviceListener echonetDeviceListener) {
+            super(instanceKey);
+            this.pollIntervalMs = pollIntervalMs;
+            this.retryTimeoutMs = retryTimeoutMs;
+            this.echonetDeviceListener = echonetDeviceListener;
+        }
+
+        @Override
+        public String toString() {
+            return "NewDeviceMessage{" + "instanceKey=" + instanceKey + ", pollIntervalMs=" + pollIntervalMs
+                    + ", retryTimeoutMs=" + retryTimeoutMs + "} " + super.toString();
+        }
+    }
+
+    private static class RefreshMessage extends Message {
+        private final String channelId;
+
+        public RefreshMessage(InstanceKey instanceKey, String channelId) {
+            super(instanceKey);
+            this.channelId = channelId;
+        }
+    }
+
+    private static class RemoveDevice extends Message {
+        public RemoveDevice(final InstanceKey instanceKey) {
+            super(instanceKey);
+        }
+    }
+
+    private static class StartDiscoveryMessage extends Message {
+        private final EchonetDiscoveryListener echonetDiscoveryListener;
+
+        public StartDiscoveryMessage(EchonetDiscoveryListener echonetDiscoveryListener, InstanceKey discoveryKey) {
+            super(discoveryKey);
+            this.echonetDiscoveryListener = echonetDiscoveryListener;
+        }
+    }
+
+    private static class StopDiscoveryMessage extends Message {
+        public StopDiscoveryMessage(InstanceKey discoveryKey) {
+            super(discoveryKey);
+        }
+    }
+
+    private static class UpdateDevice extends Message {
+        private final String channelId;
+        private final State state;
+
+        public UpdateDevice(final InstanceKey instanceKey, final String channelId, final State state) {
+            super(instanceKey);
+            this.channelId = channelId;
+            this.state = state;
+        }
+
+        public String toString() {
+            return "UpdateDevice{" + "instanceKey=" + instanceKey + ", channelId='" + channelId + '\'' + ", state="
+                    + state + "} " + super.toString();
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetLiteHandler.java b/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetLiteHandler.java
new file mode 100644 (file)
index 0000000..87297e0
--- /dev/null
@@ -0,0 +1,188 @@
+/**
+ * Copyright (c) 2010-2022 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.echonetlite.internal;
+
+import static java.util.Objects.requireNonNull;
+import static org.openhab.binding.echonetlite.internal.EchonetLiteBindingConstants.PROPERTY_NAME_INSTANCE_KEY;
+
+import java.net.InetSocketAddress;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.Channel;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.binding.BaseThingHandler;
+import org.openhab.core.thing.binding.builder.ChannelBuilder;
+import org.openhab.core.thing.binding.builder.ThingBuilder;
+import org.openhab.core.thing.type.ChannelTypeUID;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.RefreshType;
+import org.openhab.core.types.State;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link EchonetLiteHandler} is responsible for handling commands, which are
+ * sent to one of the channels.
+ *
+ * @author Michael Barker - Initial contribution
+ */
+@NonNullByDefault
+public class EchonetLiteHandler extends BaseThingHandler implements EchonetDeviceListener {
+    private final Logger logger = LoggerFactory.getLogger(EchonetLiteHandler.class);
+
+    private @Nullable InstanceKey instanceKey;
+    private final Map<String, State> stateByChannelId = new HashMap<>();
+
+    public EchonetLiteHandler(final Thing thing) {
+        super(thing);
+    }
+
+    @Nullable
+    private EchonetLiteBridgeHandler bridgeHandler() {
+        @Nullable
+        final Bridge bridge = getBridge();
+        if (null == bridge) {
+            return null;
+        }
+
+        @Nullable
+        final EchonetLiteBridgeHandler handler = (EchonetLiteBridgeHandler) bridge.getHandler();
+        return handler;
+    }
+
+    @Override
+    public void handleCommand(ChannelUID channelUID, Command command) {
+        @Nullable
+        final EchonetLiteBridgeHandler handler = bridgeHandler();
+        if (null == handler) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+                    "@text/offline.conf-error.null-bridge-handler");
+            return;
+        }
+
+        if (command instanceof RefreshType) {
+            logger.debug("Refreshing: {}", channelUID);
+
+            final State currentState = stateByChannelId.get(channelUID.getId());
+            if (null == currentState) {
+                handler.refreshDevice(requireNonNull(instanceKey), channelUID.getId());
+            } else {
+                updateState(channelUID, currentState);
+            }
+        } else if (command instanceof State) {
+            logger.debug("Updating: {} to {}", channelUID, command);
+
+            handler.updateDevice(requireNonNull(instanceKey), channelUID.getId(), (State) command);
+        }
+    }
+
+    @Override
+    public void initialize() {
+        final EchonetDeviceConfig config = getConfigAs(EchonetDeviceConfig.class);
+
+        logger.debug("Initialising: {}", config);
+
+        updateStatus(ThingStatus.UNKNOWN);
+
+        @Nullable
+        final EchonetLiteBridgeHandler bridgeHandler = bridgeHandler();
+        if (null == bridgeHandler) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+                    "@text/offline.conf-error.null-bridge-handler");
+            return;
+        }
+
+        try {
+            final InetSocketAddress address = new InetSocketAddress(requireNonNull(config.hostname), config.port);
+            final InstanceKey instanceKey = new InstanceKey(address,
+                    EchonetClass.resolve(config.groupCode, config.classCode), config.instance);
+            this.instanceKey = instanceKey;
+
+            updateProperty(PROPERTY_NAME_INSTANCE_KEY, instanceKey.representationProperty());
+            bridgeHandler.newDevice(instanceKey, config.pollIntervalMs, config.retryTimeoutMs, this);
+        } catch (Exception e) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
+        }
+    }
+
+    public void handleRemoval() {
+        @Nullable
+        final EchonetLiteBridgeHandler bridgeHandler = bridgeHandler();
+        if (null == bridgeHandler) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+                    "@text/offline.conf-error.null-bridge-handler");
+            return;
+        }
+
+        bridgeHandler.removeDevice(requireNonNull(instanceKey));
+    }
+
+    public void onInitialised(String identifier, InstanceKey instanceKey, Map<String, String> channelIdAndType) {
+        logger.debug("Initialised Channels: {}", channelIdAndType);
+
+        final List<String> toAddChannelFor = new ArrayList<>();
+
+        for (String channelId : channelIdAndType.keySet()) {
+            if (null == thing.getChannel(channelId)) {
+                toAddChannelFor.add(channelId);
+            }
+        }
+
+        logger.debug("Adding Channels: {}", toAddChannelFor);
+
+        if (!toAddChannelFor.isEmpty()) {
+            final ThingBuilder thingBuilder = editThing();
+
+            for (String channelId : toAddChannelFor) {
+                final Channel channel = ChannelBuilder.create(new ChannelUID(thing.getUID(), channelId))
+                        .withAcceptedItemType(channelIdAndType.get(channelId))
+                        .withType(new ChannelTypeUID(thing.getThingTypeUID().getBindingId(), channelId)).build();
+                thingBuilder.withChannel(channel);
+
+                logger.debug("Added Channel: {}", channel);
+            }
+
+            updateThing(thingBuilder.build());
+        }
+
+        updateStatus(ThingStatus.ONLINE);
+    }
+
+    public void onUpdated(final String channelId, final State value) {
+        stateByChannelId.put(channelId, value);
+
+        if (ThingStatus.ONLINE != getThing().getStatus()) {
+            updateStatus(ThingStatus.ONLINE);
+        }
+        updateState(channelId, value);
+    }
+
+    public void onRemoved() {
+        updateStatus(ThingStatus.REMOVED);
+    }
+
+    public void onOffline() {
+        if (ThingStatus.OFFLINE != getThing().getStatus()) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetLiteHandlerFactory.java b/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetLiteHandlerFactory.java
new file mode 100644 (file)
index 0000000..158dd29
--- /dev/null
@@ -0,0 +1,60 @@
+/**
+ * Copyright (c) 2010-2022 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.echonetlite.internal;
+
+import static org.openhab.binding.echonetlite.internal.EchonetLiteBindingConstants.THING_TYPE_ECHONET_BRIDGE;
+import static org.openhab.binding.echonetlite.internal.EchonetLiteBindingConstants.THING_TYPE_ECHONET_DEVICE;
+
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.binding.BaseThingHandlerFactory;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.openhab.core.thing.binding.ThingHandlerFactory;
+import org.osgi.service.component.annotations.Component;
+
+/**
+ * The {@link EchonetLiteHandlerFactory} is responsible for creating things and thing
+ * handlers.
+ *
+ * @author Michael Barker - Initial contribution
+ */
+@NonNullByDefault
+@Component(configurationPid = "binding.echonetlite", service = ThingHandlerFactory.class)
+public class EchonetLiteHandlerFactory extends BaseThingHandlerFactory {
+
+    private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_ECHONET_DEVICE,
+            THING_TYPE_ECHONET_BRIDGE);
+
+    @Override
+    public boolean supportsThingType(ThingTypeUID thingTypeUID) {
+        return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
+    }
+
+    @Override
+    protected @Nullable ThingHandler createHandler(Thing thing) {
+        final ThingTypeUID thingTypeUID = thing.getThingTypeUID();
+
+        if (THING_TYPE_ECHONET_DEVICE.equals(thingTypeUID)) {
+            return new EchonetLiteHandler(thing);
+        } else if (THING_TYPE_ECHONET_BRIDGE.equals(thingTypeUID)) {
+            return new EchonetLiteBridgeHandler((Bridge) thing);
+        }
+
+        return null;
+    }
+}
diff --git a/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetMessage.java b/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetMessage.java
new file mode 100644 (file)
index 0000000..061fb89
--- /dev/null
@@ -0,0 +1,134 @@
+/**
+ * Copyright (c) 2010-2022 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.echonetlite.internal;
+
+import java.net.SocketAddress;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * @author Michael Barker - Initial contribution
+ */
+@NonNullByDefault
+public class EchonetMessage {
+    public static final int TID_OFFSET = 2;
+    public static final int GROUP_OFFSET = 4;
+    public static final int CLASS_OFFSET = 5;
+    public static final int INSTANCE_OFFSET = 6;
+    public static final int ESV_OFFSET = 10;
+    public static final int OPC_OFFSET = 11;
+    public static final int PROPERTY_OFFSET = 12;
+
+    private final ByteBuffer messageData = ByteBuffer.allocateDirect(65536);
+    private final ByteBuffer propertyData = messageData.duplicate();
+    private int propertyCursor = 0;
+    private int currentProperty = -1;
+
+    @Nullable
+    private SocketAddress address;
+
+    public ByteBuffer bufferForRead() {
+        reset();
+        return messageData;
+    }
+
+    private void reset() {
+        messageData.clear();
+        messageData.order(ByteOrder.BIG_ENDIAN);
+        propertyCursor = 0;
+        currentProperty = -1;
+    }
+
+    public void sourceAddress(final SocketAddress address) {
+        this.address = address;
+    }
+
+    public @Nullable SocketAddress sourceAddress() {
+        return address;
+    }
+
+    public @Nullable EchonetClass sourceClass() {
+        return EchonetClassIndex.INSTANCE.lookup(messageData.get(GROUP_OFFSET), messageData.get(CLASS_OFFSET));
+    }
+
+    public byte instance() {
+        return messageData.get(INSTANCE_OFFSET);
+    }
+
+    public Esv esv() {
+        return Esv.forCode(messageData.get(ESV_OFFSET));
+    }
+
+    public int numProperties() {
+        return 0xFF & messageData.get(OPC_OFFSET);
+    }
+
+    public boolean moveNext() {
+        if (propertyCursor < numProperties()) {
+            propertyCursor++;
+            if (-1 == currentProperty) {
+                currentProperty = PROPERTY_OFFSET;
+            } else {
+                int pdc = 0xFF & messageData.get(currentProperty + 1);
+                currentProperty = currentProperty + 2 + pdc;
+            }
+            return true;
+        }
+
+        return false;
+    }
+
+    public int currentEpc() {
+        return messageData.get(currentProperty) & 0xFF;
+    }
+
+    public int currentPdc() {
+        return messageData.get(currentProperty + 1) & 0xFF;
+    }
+
+    public ByteBuffer currentEdt() {
+        propertyData.clear();
+        propertyData.position(currentProperty + 2).limit(currentProperty + 2 + currentPdc());
+        return propertyData;
+    }
+
+    public short tid() {
+        return messageData.getShort(TID_OFFSET);
+    }
+
+    public String toDebug() {
+        return "EchonetMessage{" + "sourceAddress=" + sourceAddress() + ", class=" + sourceClass() + ", instance="
+                + instance() + ", num properties=" + numProperties() + ", data=" + dumpData() + '}';
+    }
+
+    private String dumpData() {
+        final byte[] bs = new byte[messageData.limit()];
+        final ByteBuffer duplicate = messageData.duplicate();
+        duplicate.position(0).limit(messageData.limit());
+        duplicate.get(bs);
+
+        final StringBuilder sb = new StringBuilder();
+
+        sb.append('[');
+        for (byte b : bs) {
+            sb.append("0x").append(Integer.toHexString(0xFF & b)).append(", ");
+        }
+        sb.setLength(sb.length() - 2);
+        sb.append(']');
+
+        return sb.toString();
+    }
+}
diff --git a/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetMessageBuilder.java b/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetMessageBuilder.java
new file mode 100644 (file)
index 0000000..2282eca
--- /dev/null
@@ -0,0 +1,103 @@
+/**
+ * Copyright (c) 2010-2022 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.echonetlite.internal;
+
+import static org.openhab.binding.echonetlite.internal.LangUtil.b;
+
+import java.net.InetSocketAddress;
+import java.net.SocketAddress;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * @author Michael Barker - Initial contribution
+ */
+@NonNullByDefault
+public class EchonetMessageBuilder {
+    private static final byte EHD_1 = 0x10;
+    private static final byte EHD_2 = (byte) (0x81 & 0xFF);
+
+    private final ByteBuffer buffer;
+    private final ByteBuffer edtBuffer = ByteBuffer.allocate(4096);
+    private int opcPosition = 0;
+    @Nullable
+    private InetSocketAddress destAddress;
+
+    public EchonetMessageBuilder() {
+        buffer = ByteBuffer.allocateDirect(4096).order(ByteOrder.BIG_ENDIAN);
+    }
+
+    public void start(short tid, InstanceKey source, InstanceKey dest, Esv service) {
+        // 1081000005ff010ef0006201d60100
+        // 1081000105ff010ef0006201d600
+        // 0000 10 81 00 00 05 ff 01 0e f0 00 62 01 d6 01 00
+        // 0000 10 81 00 01 05 ff 01 0e f0 00 62 01 d6 00
+
+        destAddress = dest.address;
+
+        buffer.clear();
+        buffer.put(EHD_1);
+        buffer.put(EHD_2);
+        buffer.putShort(tid);
+        buffer.put(b(source.klass.groupCode()));
+        buffer.put(b(source.klass.classCode()));
+        buffer.put(b(source.instance));
+        buffer.put(b(dest.klass.groupCode()));
+        buffer.put(b(dest.klass.classCode()));
+        buffer.put(b(dest.instance));
+        buffer.put(service.code());
+
+        opcPosition = buffer.position();
+        buffer.put((byte) 0);
+    }
+
+    private void incrementOpc() {
+        buffer.put(opcPosition, (byte) (buffer.get(opcPosition) + 1));
+    }
+
+    public void append(final byte edt, final byte length, final byte value) {
+        buffer.put(edt).put(length).put(value);
+        incrementOpc();
+    }
+
+    public void appendEpcRequest(final int epc) {
+        buffer.put(b(epc)).put((byte) 0);
+        incrementOpc();
+    }
+
+    public ByteBuffer buffer() {
+        return buffer;
+    }
+
+    @Nullable
+    public SocketAddress address() {
+        return destAddress;
+    }
+
+    public ByteBuffer edtBuffer() {
+        edtBuffer.clear();
+        return edtBuffer;
+    }
+
+    public void appendEpcUpdate(final int epc, ByteBuffer edtBuffer) {
+        if (edtBuffer.remaining() < 0 || 255 < edtBuffer.remaining()) {
+            throw new IllegalArgumentException("Invalid update value, length: " + edtBuffer.remaining());
+        }
+
+        buffer.put(b(epc)).put(b(edtBuffer.remaining())).put(edtBuffer);
+        incrementOpc();
+    }
+}
diff --git a/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetObject.java b/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetObject.java
new file mode 100644 (file)
index 0000000..28d918e
--- /dev/null
@@ -0,0 +1,200 @@
+/**
+ * Copyright (c) 2010-2022 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.echonetlite.internal;
+
+import static org.openhab.binding.echonetlite.internal.EchonetLiteBindingConstants.DEFAULT_RETRY_TIMEOUT_MS;
+
+import java.nio.ByteBuffer;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.types.State;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * @author Michael Barker - Initial contribution
+ */
+@NonNullByDefault
+public abstract class EchonetObject {
+
+    private final Logger logger = LoggerFactory.getLogger(EchonetObject.class);
+
+    protected final InstanceKey instanceKey;
+    protected final HashSet<Epc> pendingGets = new HashSet<>();
+
+    protected InflightRequest inflightGetRequest = new InflightRequest(DEFAULT_RETRY_TIMEOUT_MS, "GET");
+    protected InflightRequest inflightSetRequest = new InflightRequest(DEFAULT_RETRY_TIMEOUT_MS, "SET");
+
+    protected long pollIntervalMs;
+
+    public EchonetObject(final InstanceKey instanceKey, final Epc initialProperty) {
+        this.instanceKey = instanceKey;
+        pendingGets.add(initialProperty);
+    }
+
+    public InstanceKey instanceKey() {
+        return instanceKey;
+    }
+
+    public void applyProperty(InstanceKey sourceInstanceKey, Esv esv, final int epcCode, final int pdc,
+            final ByteBuffer edt) {
+    }
+
+    public boolean buildPollMessage(final EchonetMessageBuilder messageBuilder, final ShortSupplier tidSupplier,
+            long nowMs, InstanceKey managementControllerKey) {
+        if (pendingGets.isEmpty()) {
+            return false;
+        }
+
+        if (hasInflight(nowMs, this.inflightGetRequest)) {
+            return false;
+        }
+
+        final short tid = tidSupplier.getAsShort();
+        messageBuilder.start(tid, managementControllerKey, instanceKey(), Esv.Get);
+
+        for (Epc pendingProperty : pendingGets) {
+            messageBuilder.appendEpcRequest(pendingProperty.code());
+        }
+
+        this.inflightGetRequest.requestSent(tid, nowMs);
+
+        return true;
+    }
+
+    protected boolean hasInflight(long nowMs, InflightRequest inflightRequest) {
+        if (inflightRequest.isInflight()) {
+            return !inflightRequest.hasTimedOut(nowMs);
+        }
+        return false;
+    }
+
+    protected void setTimeouts(long pollIntervalMs, long retryTimeoutMs) {
+        this.pollIntervalMs = pollIntervalMs;
+        this.inflightGetRequest = new InflightRequest(retryTimeoutMs, inflightGetRequest);
+        this.inflightSetRequest = new InflightRequest(retryTimeoutMs, inflightSetRequest);
+    }
+
+    public boolean buildUpdateMessage(final EchonetMessageBuilder messageBuilder, final ShortSupplier tid,
+            final long nowMs, InstanceKey managementControllerKey) {
+        return false;
+    }
+
+    public void refreshAll(long nowMs) {
+    }
+
+    public String toString() {
+        return "ItemBase{" + "instanceKey=" + instanceKey + ", pendingProperties=" + pendingGets + '}';
+    }
+
+    public void update(String channelId, State state) {
+    }
+
+    public void removed() {
+    }
+
+    public void refresh(String channelId) {
+    }
+
+    public void applyHeader(Esv esv, short tid, long nowMs) {
+        if ((esv == Esv.Get_Res || esv == Esv.Get_SNA)) {
+            final long sentTimestampMs = this.inflightGetRequest.timestampMs;
+            if (this.inflightGetRequest.responseReceived(tid)) {
+                logger.debug("{} response time: {}ms", esv, nowMs - sentTimestampMs);
+            } else {
+                logger.warn("Unexpected {} response: {}", esv, tid);
+                this.inflightGetRequest.checkOldResponse(tid, nowMs);
+            }
+        } else if ((esv == Esv.Set_Res || esv == Esv.SetC_SNA)) {
+            final long sentTimestampMs = this.inflightSetRequest.timestampMs;
+            if (this.inflightSetRequest.responseReceived(tid)) {
+                logger.debug("{} response time: {}ms", esv, nowMs - sentTimestampMs);
+            } else {
+                logger.warn("Unexpected {} response: {}", esv, tid);
+                this.inflightSetRequest.checkOldResponse(tid, nowMs);
+            }
+        }
+    }
+
+    public void checkTimeouts() {
+    }
+
+    protected static class InflightRequest {
+        private static final long NULL_TIMESTAMP = -1;
+
+        private final Logger logger = LoggerFactory.getLogger(InflightRequest.class);
+        private final long timeoutMs;
+        private final String name;
+        private final Map<Short, Long> oldRequests = new HashMap<>();
+
+        private short tid;
+        private long timestampMs = NULL_TIMESTAMP;
+        @SuppressWarnings("unused")
+        private int timeoutCount = 0;
+
+        InflightRequest(long timeoutMs, InflightRequest existing) {
+            this(timeoutMs, existing.name);
+            this.tid = existing.tid;
+            this.timestampMs = existing.timestampMs;
+        }
+
+        InflightRequest(long timeoutMs, String name) {
+            this.timeoutMs = timeoutMs;
+            this.name = name;
+        }
+
+        void requestSent(short tid, long timestampMs) {
+            this.tid = tid;
+            this.timestampMs = timestampMs;
+        }
+
+        boolean responseReceived(short tid) {
+            timestampMs = NULL_TIMESTAMP;
+            timeoutCount = 0;
+
+            return this.tid == tid;
+        }
+
+        boolean hasTimedOut(long nowMs) {
+            final boolean timedOut = timestampMs + timeoutMs <= nowMs;
+            if (timedOut) {
+                logger.debug("Timed out {}, tid={}, timestampMs={} + timeoutMs={} <= nowMs={}", name, tid, timestampMs,
+                        timeoutMs, nowMs);
+                timeoutCount++;
+
+                if (NULL_TIMESTAMP != tid) {
+                    oldRequests.put(tid, timestampMs);
+                }
+            }
+            return timedOut;
+        }
+
+        public boolean isInflight() {
+            return NULL_TIMESTAMP != timestampMs;
+        }
+
+        public void checkOldResponse(short tid, long nowMs) {
+            final Long oldResponseTimestampMs = oldRequests.remove(tid);
+            if (null != oldResponseTimestampMs) {
+                logger.debug("Timed out request, tid={}, actually took={}", tid, nowMs - oldResponseTimestampMs);
+            }
+        }
+
+        public int timeoutCount() {
+            return timeoutCount;
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetProfileNode.java b/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetProfileNode.java
new file mode 100644 (file)
index 0000000..af4046a
--- /dev/null
@@ -0,0 +1,82 @@
+/**
+ * Copyright (c) 2010-2022 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.echonetlite.internal;
+
+import static org.openhab.binding.echonetlite.internal.EchonetLiteBindingConstants.DEFAULT_POLL_INTERVAL_MS;
+import static org.openhab.binding.echonetlite.internal.EchonetLiteBindingConstants.DEFAULT_RETRY_TIMEOUT_MS;
+
+import java.nio.ByteBuffer;
+import java.util.Map;
+import java.util.function.Consumer;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * @author Michael Barker - Initial contribution
+ */
+@NonNullByDefault
+public class EchonetProfileNode extends EchonetObject implements EchonetDeviceListener {
+
+    private final Consumer<EchonetDevice> newDeviceListener;
+    private final EchonetDiscoveryListener echonetDiscoveryListener;
+    private long lastPollMs = 0;
+
+    public EchonetProfileNode(final InstanceKey instanceKey, Consumer<EchonetDevice> newDeviceListener,
+            EchonetDiscoveryListener echonetDiscoveryListener) {
+        super(instanceKey, Epc.NodeProfile.SELF_NODE_INSTANCE_LIST_S);
+        this.newDeviceListener = newDeviceListener;
+        this.echonetDiscoveryListener = echonetDiscoveryListener;
+        setTimeouts(DEFAULT_POLL_INTERVAL_MS, DEFAULT_RETRY_TIMEOUT_MS);
+    }
+
+    @Override
+    public void applyProperty(InstanceKey sourceInstanceKey, Esv esv, int epcCode, int pdc, ByteBuffer edt) {
+        final Epc epc = Epc.lookup(instanceKey().klass.groupCode(), instanceKey().klass.classCode(), epcCode);
+
+        if (EchonetClass.NODE_PROFILE == sourceInstanceKey.klass && Epc.NodeProfile.SELF_NODE_INSTANCE_LIST_S == epc) {
+            final int selfNodeInstanceCount = edt.get() & 0xFF;
+
+            for (int i = 0; i < selfNodeInstanceCount && edt.hasRemaining(); i++) {
+                final byte groupCode = edt.get();
+                final byte classCode = edt.get();
+                final byte instance = edt.get();
+                final EchonetClass itemClass = EchonetClassIndex.INSTANCE.lookup(groupCode, classCode);
+
+                final InstanceKey newItemKey = new InstanceKey(sourceInstanceKey.address, itemClass, instance);
+                final EchonetDevice discoveredDevice = new EchonetDevice(newItemKey, this);
+                discoveredDevice.setTimeouts(DEFAULT_POLL_INTERVAL_MS, DEFAULT_RETRY_TIMEOUT_MS);
+                newDeviceListener.accept(discoveredDevice);
+            }
+        }
+    }
+
+    @Override
+    public boolean buildPollMessage(EchonetMessageBuilder messageBuilder, ShortSupplier tidSupplier, long nowMs,
+            InstanceKey managementControllerKey) {
+        boolean result = false;
+        if (lastPollMs + pollIntervalMs <= nowMs) {
+            result = super.buildPollMessage(messageBuilder, tidSupplier, nowMs, managementControllerKey);
+
+            if (result) {
+                lastPollMs = nowMs;
+            }
+        }
+
+        return result;
+    }
+
+    @Override
+    public void onInitialised(String identifier, InstanceKey instanceKey, Map<String, String> channelIdAndType) {
+        echonetDiscoveryListener.onDeviceFound(identifier, instanceKey);
+    }
+}
diff --git a/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetPropertyMap.java b/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EchonetPropertyMap.java
new file mode 100644 (file)
index 0000000..40d1caf
--- /dev/null
@@ -0,0 +1,92 @@
+/**
+ * Copyright (c) 2010-2022 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.echonetlite.internal;
+
+import java.nio.ByteBuffer;
+import java.util.Collection;
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * @author Michael Barker - Initial contribution
+ */
+@NonNullByDefault
+public class EchonetPropertyMap {
+    private static final int[][] PROPERTY_MAP = { { 0x80, 0x90, 0xA0, 0xB0, 0xC0, 0xD0, 0xE0, 0xF0, },
+            { 0x81, 0x91, 0xA1, 0xB1, 0xC1, 0xD1, 0xE1, 0xF1, }, { 0x82, 0x92, 0xA2, 0xB2, 0xC2, 0xD2, 0xE2, 0xF2, },
+            { 0x83, 0x93, 0xA3, 0xB3, 0xC3, 0xD3, 0xE3, 0xF3, }, { 0x84, 0x94, 0xA4, 0xB4, 0xC4, 0xD4, 0xE4, 0xF4, },
+            { 0x85, 0x95, 0xA5, 0xB5, 0xC5, 0xD5, 0xE5, 0xF5, }, { 0x86, 0x96, 0xA6, 0xB6, 0xC6, 0xD6, 0xE6, 0xF6, },
+            { 0x87, 0x97, 0xA7, 0xB7, 0xC7, 0xD7, 0xE7, 0xF7, }, { 0x88, 0x98, 0xA8, 0xB8, 0xC8, 0xD8, 0xE8, 0xF8, },
+            { 0x89, 0x99, 0xA9, 0xB9, 0xC9, 0xD9, 0xE9, 0xF9, }, { 0x8A, 0x9A, 0xAA, 0xBA, 0xCA, 0xDA, 0xEA, 0xFA, },
+            { 0x8B, 0x9B, 0xAB, 0xBB, 0xCB, 0xDB, 0xEB, 0xFB, }, { 0x8C, 0x9C, 0xAC, 0xBC, 0xCC, 0xDC, 0xEC, 0xFC, },
+            { 0x8D, 0x9D, 0xAD, 0xBD, 0xCD, 0xDD, 0xED, 0xFD, }, { 0x8E, 0x9E, 0xAE, 0xBE, 0xCE, 0xDE, 0xEE, 0xFE, },
+            { 0x8F, 0x9F, 0xAF, 0xBF, 0xCF, 0xDF, 0xEF, 0xFF, }, };
+
+    private int[] propertyMap = {};
+    private final Epc epc;
+
+    public EchonetPropertyMap(final Epc epc) {
+        this.epc = epc;
+    }
+
+    public Epc epc() {
+        return epc;
+    }
+
+    public void update(final ByteBuffer edt) {
+        propertyMap = parsePropertyMap(edt);
+    }
+
+    public void getProperties(int groupCode, int classCode, final Set<Epc> existing, Collection<Epc> toFill) {
+        for (int epcCode : propertyMap) {
+            final Epc epc = Epc.lookup(groupCode, classCode, epcCode);
+            if (!existing.contains(epc)) {
+                toFill.add(epc);
+            }
+        }
+    }
+
+    static int[] parsePropertyMap(final ByteBuffer buffer) {
+        final int numProperties = buffer.get() & 0xFF;
+        final int[] properties = new int[numProperties];
+        int propertyIndex = 0;
+        if (numProperties < 16) {
+            for (int i = 0; i < numProperties; i++) {
+                properties[propertyIndex] = (buffer.get() & 0xFF);
+                propertyIndex++;
+            }
+        } else {
+            assert 16 == buffer.remaining();
+
+            for (int i = 0; i < 16; i++) {
+                int b = buffer.get() & 0xFF;
+                for (int j = 0; j < 8; j++) {
+                    if (0 != (b & (1 << j))) {
+                        assert propertyIndex < properties.length;
+
+                        properties[propertyIndex] = PROPERTY_MAP[i][j];
+                        propertyIndex++;
+                    }
+                }
+            }
+        }
+
+        assert propertyIndex == properties.length;
+        return properties;
+    }
+
+    public String toString() {
+        return "EnPropertyMap{" + "propertyMap=" + HexUtil.hex(propertyMap) + '}';
+    }
+}
diff --git a/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/Epc.java b/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/Epc.java
new file mode 100644 (file)
index 0000000..b09b823
--- /dev/null
@@ -0,0 +1,487 @@
+/**
+ * Copyright (c) 2010-2022 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.echonetlite.internal;
+
+import static org.openhab.binding.echonetlite.internal.EchonetLiteBindingConstants.ON_OFF_CODEC_30_31;
+import static org.openhab.binding.echonetlite.internal.EchonetLiteBindingConstants.ON_OFF_CODEC_41_42;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.echonetlite.internal.StateCodec.Option;
+import org.openhab.binding.echonetlite.internal.StateCodec.OptionCodec;
+
+/**
+ * @author Michael Barker - Initial contribution
+ */
+@NonNullByDefault
+public interface Epc {
+    int code();
+
+    String name();
+
+    @Nullable
+    default String type() {
+        return null;
+    }
+
+    default String channelId() {
+        return LangUtil.constantToVariable(name());
+    }
+
+    @Nullable
+    default StateDecode decoder() {
+        return null;
+    }
+
+    @Nullable
+    default StateEncode encoder() {
+        return null;
+    }
+
+    static Epc lookup(int groupCode, int classCode, int epcCode) {
+        return EpcLookupTable.INSTANCE.resolve(groupCode, classCode, epcCode);
+    }
+
+    // ECHONET SPECIFICATION
+    // APPENDIX Detailed Requirements for ECHONET Device objects
+    // Table 2-1
+    enum Device implements Epc {
+        // @formatter:off
+        OPERATION_STATUS(0x80, ON_OFF_CODEC_30_31),
+
+        INSTALLATION_LOCATION(0x81, new OptionCodec(
+                new Option("Not specified", 0b00000_000),
+                
+                new Option("Living Room", 0b00001_000),
+                new Option("Living Room 1", 0b00001_001),
+                new Option("Living Room 2", 0b00001_010),
+                new Option("Living Room 3", 0b00001_011),
+                new Option("Living Room 4", 0b00001_100),
+                new Option("Living Room 5", 0b00001_101),
+                new Option("Living Room 6", 0b00001_110),
+                new Option("Living Room 7", 0b00001_111),
+                
+                new Option("Dining Room", 0b00010_000),
+                new Option("Dining Room 1", 0b00010_001),
+                new Option("Dining Room 2", 0b00010_010),
+                new Option("Dining Room 3", 0b00010_011),
+                new Option("Dining Room 4", 0b00010_100),
+                new Option("Dining Room 5", 0b00010_101),
+                new Option("Dining Room 6", 0b00010_110),
+                new Option("Dining Room 7", 0b00010_111),
+               
+                new Option("Kitchen", 0b00011_000),
+                new Option("Kitchen 1", 0b00011_001),
+                new Option("Kitchen 2", 0b00011_010),
+                new Option("Kitchen 3", 0b00011_011),
+                new Option("Kitchen 4", 0b00011_100),
+                new Option("Kitchen 5", 0b00011_101),
+                new Option("Kitchen 6", 0b00011_110),
+                new Option("Kitchen 7", 0b00011_111),
+                
+                new Option("Lavatory", 0b00100_000),
+                new Option("Lavatory 1", 0b00100_001),
+                new Option("Lavatory 2", 0b00100_010),
+                new Option("Lavatory 3", 0b00100_011),
+                new Option("Lavatory 4", 0b00100_100),
+                new Option("Lavatory 5", 0b00100_101),
+                new Option("Lavatory 6", 0b00100_110),
+                new Option("Lavatory 7", 0b00100_111),
+                
+                new Option("Washroom/changing room", 0b00101_000),
+                new Option("Washroom/changing room 1", 0b00101_001),
+                new Option("Washroom/changing room 2", 0b00101_010),
+                new Option("Washroom/changing room 3", 0b00101_011),
+                new Option("Washroom/changing room 4", 0b00101_100),
+                new Option("Washroom/changing room 5", 0b00101_101),
+                new Option("Washroom/changing room 6", 0b00101_110),
+                new Option("Washroom/changing room 7", 0b00101_111),
+                
+                new Option("Passageway", 0b00111_000),
+                new Option("Passageway  1", 0b00111_001),
+                new Option("Passageway  2", 0b00111_010),
+                new Option("Passageway  3", 0b00111_011),
+                new Option("Passageway  4", 0b00111_100),
+                new Option("Passageway  5", 0b00111_101),
+                new Option("Passageway  6", 0b00111_110),
+                new Option("Passageway  7", 0b00111_111),
+                
+                new Option("Room", 0b01000_000),
+                new Option("Room 1", 0b01000_001),
+                new Option("Room 2", 0b01000_010),
+                new Option("Room 3", 0b01000_011),
+                new Option("Room 4", 0b01000_100),
+                new Option("Room 5", 0b01000_101),
+                new Option("Room 6", 0b01000_110),
+                new Option("Room 7", 0b01000_111),
+                
+                new Option("Stairway", 0b01001_000),
+                new Option("Stairway 1", 0b01001_001),
+                new Option("Stairway 2", 0b01001_010),
+                new Option("Stairway 3", 0b01001_011),
+                new Option("Stairway 4", 0b01001_100),
+                new Option("Stairway 5", 0b01001_101),
+                new Option("Stairway 6", 0b01001_110),
+                new Option("Stairway 7", 0b01001_111),
+                
+                new Option("Front door", 0b01010_000),
+                new Option("Front door 1", 0b01010_001),
+                new Option("Front door 2", 0b01010_010),
+                new Option("Front door 3", 0b01010_011),
+                new Option("Front door 4", 0b01010_100),
+                new Option("Front door 5", 0b01010_101),
+                new Option("Front door 6", 0b01010_110),
+                new Option("Front door 7", 0b01010_111),
+                
+                new Option("Storeroom", 0b01011_000),
+                new Option("Storeroom 1", 0b01011_001),
+                new Option("Storeroom 2", 0b01011_010),
+                new Option("Storeroom 3", 0b01011_011),
+                new Option("Storeroom 4", 0b01011_100),
+                new Option("Storeroom 5", 0b01011_101),
+                new Option("Storeroom 6", 0b01011_110),
+                new Option("Storeroom 7", 0b01011_111),
+                
+                new Option("Garden/perimeter", 0b01100_000),
+                new Option("Garden/perimeter 1", 0b01100_001),
+                new Option("Garden/perimeter 2", 0b01100_010),
+                new Option("Garden/perimeter 3", 0b01100_011),
+                new Option("Garden/perimeter 4", 0b01100_100),
+                new Option("Garden/perimeter 5", 0b01100_101),
+                new Option("Garden/perimeter 6", 0b01100_110),
+                new Option("Garden/perimeter 7", 0b01100_111),
+                
+                new Option("Garage", 0b01101_000),
+                new Option("Garage 1", 0b01101_001),
+                new Option("Garage 2", 0b01101_010),
+                new Option("Garage 3", 0b01101_011),
+                new Option("Garage 4", 0b01101_100),
+                new Option("Garage 5", 0b01101_101),
+                new Option("Garage 6", 0b01101_110),
+                new Option("Garage 7", 0b01101_111),
+                
+                new Option("Veranda/balcony", 0b01110_000),
+                new Option("Veranda/balcony 1", 0b01110_001),
+                new Option("Veranda/balcony 2", 0b01110_010),
+                new Option("Veranda/balcony 3", 0b01110_011),
+                new Option("Veranda/balcony 4", 0b01110_100),
+                new Option("Veranda/balcony 5", 0b01110_101),
+                new Option("Veranda/balcony 6", 0b01110_110),
+                new Option("Veranda/balcony 7", 0b01110_111),
+                
+                new Option("Others", 0b01111_000),
+                new Option("Others 1", 0b01111_001),
+                new Option("Others 2", 0b01111_010),
+                new Option("Others 3", 0b01111_011),
+                new Option("Others 4", 0b01111_100),
+                new Option("Others 5", 0b01111_101),
+                new Option("Others 6", 0b01111_110),
+                new Option("Others 7", 0b01111_111))),
+        
+        STANDARD_VERSION_INFORMATION(0x82, StateCodec.StandardVersionInformationCodec.INSTANCE, null),
+        IDENTIFICATION_NUMBER(0x83, StateCodec.HexStringCodec.INSTANCE, null),
+        MEASURED_INSTANTANEOUS_POWER_CONSUMPTION(0x84),
+        MEASURED_CUMULATIVE_POWER_CONSUMPTION(0x85),
+        MANUFACTURER_FAULT_CODE(0x86, StateCodec.HexStringCodec.INSTANCE, null),
+        CURRENT_LIMIT_SETTING(0x87),
+        FAULT_STATUS(0x88, ON_OFF_CODEC_41_42, null),
+        FAULT_DESCRIPTION(0x89, StateCodec.HexStringCodec.INSTANCE, null),
+        MANUFACTURER_CODE(0x8A, StateCodec.HexStringCodec.INSTANCE, null),
+        BUSINESS_FACILITY_CODE(0x8B, StateCodec.HexStringCodec.INSTANCE, null),
+        PRODUCT_CODE(0x8C),
+        PRODUCTION_NUMBER(0x8D),
+        PRODUCTION_DATE(0x8E),
+        POWER_SAVING_OPERATION_SETTING(0x8F, ON_OFF_CODEC_41_42),
+        REMOTE_CONTROL_SETTING(0x93),
+        CURRENT_TIME_SETTING(0x97),
+        CURRENT_DATE_SETTING(0x98),
+        POWER_LIMIT_SETTING(0x99),
+        CUMULATIVE_OPERATING_TIME(0x9A, StateCodec.OperatingTimeDecode.INSTANCE, null),
+        SETM_PROPERTY_MAP(0x9B),
+        GETM_PROPERTY_MAP(0x9C),
+        STATUS_CHANGE_ANNOUNCEMENT_PROPERTY_MAP(0x9D),
+        SET_PROPERTY_MAP(0x9E),
+        GET_PROPERTY_MAP(0x9F);
+        // @formatter:on
+
+        public final int code;
+        @Nullable
+        public final StateDecode stateDecode;
+        @Nullable
+        public final StateEncode stateEncode;
+
+        Device(int code) {
+            this(code, null, null);
+        }
+
+        Device(int code, @Nullable StateDecode stateDecode, @Nullable StateEncode stateEncode) {
+            this.code = code;
+            this.stateDecode = stateDecode;
+            this.stateEncode = stateEncode;
+        }
+
+        Device(int code, StateCodec stateCodec) {
+            this(code, stateCodec, stateCodec);
+        }
+
+        public int code() {
+            return code;
+        }
+
+        @Nullable
+        public StateDecode decoder() {
+            return stateDecode;
+        }
+
+        @Nullable
+        public StateEncode encoder() {
+            return stateEncode;
+        }
+    }
+
+    enum AcGroup implements Epc {
+        // @formatter:off
+        AIR_FLOW_RATE(0xA0, new OptionCodec(
+                new Option("Auto", 0x41), 
+                new Option("Rate 1", 0x31), 
+                new Option("Rate 2", 0x32),
+                new Option("Rate 3", 0x33),
+                new Option("Rate 4", 0x34),
+                new Option("Rate 5", 0x35),
+                new Option("Rate 6", 0x36),
+                new Option("Rate 7", 0x37),
+                new Option("Rate 8", 0x38))),
+        
+        AUTOMATIC_CONTROL_OF_AIR_FLOW_DIRECTION(0xA1, new OptionCodec(
+                new Option("Automatic", 0x41),
+                new Option("Non-automatic", 0x42),
+                new Option("Automatic (vertical)", 0x43),
+                new Option("Automatic (horizontal)", 0x44))),
+        
+        AUTOMATIC_SWING_OF_AIR_FLOW(0xA3, new OptionCodec(
+                new Option("Not used", 0x31),
+                new Option("Used (vertical)", 0x41),
+                new Option("Used (horizontal)", 0x42),
+                new Option("Used (vertical and horizontal)", 0x43))),
+
+        AIR_FLOW_DIRECTION_VERTICAL(0xA4, new OptionCodec(
+                new Option("Uppermost", 0x41),
+                new Option("Lowermost", 0x42),
+                new Option("Mid-uppermost", 0x43),
+                new Option("Mid-lowermost", 0x44),
+                new Option("Central", 0x45))),
+
+        AIR_FLOW_DIRECTION_HORIZONTAL(0xA5, new OptionCodec(
+                new Option("XXXOO", 0x41),
+                new Option("OOXXX", 0x42),
+                new Option("XOOOX", 0x43),
+                new Option("OOXOO", 0x44),
+                new Option("XXXXO", 0x51),
+                new Option("XXXOX", 0x52),
+                new Option("XXOXX", 0x54),
+                new Option("XXOXO", 0x55),
+                new Option("XXOOX", 0x56),
+                new Option("XXOOO", 0x57),
+                new Option("XOXXX", 0x58),
+                new Option("XOXXO", 0x59),
+                new Option("XOXOX", 0x5A),
+                new Option("XOXOO", 0x5B),
+                new Option("XOOXX", 0x5C),
+                new Option("XOOXO", 0x5D),
+                new Option("XOOOO", 0x5F),
+                new Option("OXXXX", 0x60),
+                new Option("OXXXO", 0x61),
+                new Option("OXXOX", 0x62),
+                new Option("OXXOO", 0x63),
+                new Option("OXOXX", 0x64),
+                new Option("OXOXO", 0x65),
+                new Option("OXOOX", 0x66),
+                new Option("OXOOO", 0x67),
+                new Option("OOXXO", 0x69),
+                new Option("OOXOX", 0x6A),
+                new Option("OOOXX", 0x6C),
+                new Option("OOOXO", 0x6D),
+                new Option("OOOOX", 0x6E),
+                new Option("OOOOO", 0x6F))),
+
+        SPECIAL_STATE(0xAA),
+        NON_PRIORITY_STATE(0xAB),
+        OPERATION_MODE(0xB0, new OptionCodec(
+                new Option("Automatic", 0x41),
+                new Option("Cooling", 0x42),
+                new Option("Heating", 0x43),
+                new Option("Dry", 0x44),
+                new Option("Fan", 0x45),
+                new Option("Other", 0x40))),
+
+        AUTOMATIC_TEMPERATURE_CONTROL(0xB1),
+        NORMAL_HIGH_SPEED_SILENT_OPERATION(0xB2),
+        SET_TEMPERATURE(0xB3, StateCodec.Temperature8bitCodec.INSTANCE),
+        SET_RELATIVE_HUMIDITY(0xB4),
+        SET_TEMPERATURE_COOLING_MODE(0xB5),
+        SET_TEMPERATURE_HEATING_MODE(0xB6),
+        SET_TEMPERATURE_DEHUMIDIFYING_MODE(0xB7),
+        RATED_POWER_CONSUMPTION(0xB8),
+        MEASURED_CURRENT_CONSUMPTION(0xB9),
+        MEASURED_ROOM_RELATIVE_HUMIDITY(0xBA),
+        MEASURED_ROOM_TEMPERATURE(0xBB, StateCodec.Temperature8bitCodec.INSTANCE, null),
+        SET_TEMPERATURE_USER_REMOTE_CONTROL(0xBC),
+        MEASURED_COOLED_AIR_TEMPERATURE(0xBD),
+        MEASURED_OUTDOOR_TEMPERATURE(0xBE, StateCodec.Temperature8bitCodec.INSTANCE, null),
+        RELATIVE_TEMPERATURE(0xBF);
+        // @formatter:on
+
+        public final int code;
+
+        @Nullable
+        public final StateDecode stateDecode;
+
+        @Nullable
+        public final StateEncode stateEncode;
+
+        AcGroup(int code) {
+            this(code, null, null);
+        }
+
+        AcGroup(int code, @Nullable StateDecode stateDecode, @Nullable StateEncode stateEncode) {
+            this.code = code;
+            this.stateDecode = stateDecode;
+            this.stateEncode = stateEncode;
+        }
+
+        AcGroup(int code, StateCodec stateCodec) {
+            this(code, stateCodec, stateCodec);
+        }
+
+        public int code() {
+            return code;
+        }
+
+        @Nullable
+        public StateDecode decoder() {
+            return stateDecode;
+        }
+
+        @Nullable
+        public StateEncode encoder() {
+            return stateEncode;
+        }
+    }
+
+    enum HomeAc implements Epc {
+        VENTILATION_FUNCTION(0xC0),
+        HUMIDIFIER_FUNCTION(0xC1),
+        VENTILATION_AIR_FLOW_RATE(0xC3);
+
+        public final int code;
+
+        HomeAc(int code) {
+            this.code = code;
+        }
+
+        public int code() {
+            return code;
+        }
+    }
+
+    enum Profile implements Epc {
+        OPERATING_STATUS(0x80, new OptionCodec(new Option("Booting", 0x30), new Option("Not booting", 0x31))),
+        VERSION_INFORMATION(0x82),
+        NODE_IDENTIFICATION_NUMBER(0x83),
+        FAULT_CONTENT(0x89);
+
+        public final int code;
+
+        @Nullable
+        public final StateDecode stateDecode;
+        @Nullable
+        public final StateEncode stateEncode;
+
+        Profile(int code) {
+            this(code, null, null);
+        }
+
+        Profile(int code, @Nullable StateDecode stateDecode, @Nullable StateEncode stateEncode) {
+            this.code = code;
+            this.stateDecode = stateDecode;
+            this.stateEncode = stateEncode;
+        }
+
+        Profile(int code, StateCodec stateCodec) {
+            this(code, stateCodec, stateCodec);
+        }
+
+        public int code() {
+            return code;
+        }
+
+        @Nullable
+        public StateDecode decoder() {
+            return stateDecode;
+        }
+
+        @Nullable
+        public StateEncode encoder() {
+            return stateEncode;
+        }
+    }
+
+    enum ProfileGroup implements Epc {
+        UNIQUE_IDENTIFIER_CODE(0xBF);
+
+        public final int code;
+
+        ProfileGroup(int code) {
+            this.code = code;
+        }
+
+        public int code() {
+            return code;
+        }
+    }
+
+    enum NodeProfile implements Epc {
+        EA(0xE0),
+        NET_ID(0xE1),
+        NODE_D(0xE2),
+        DEFAULT_ROUTER_DATA(0xE3),
+        ALL_ROUTER_DATA(0xE4),
+        LOCK_CONTROL_STATUS(0xEE),
+        LOCK_CONTROL_DATA(0xEF),
+        SECURE_COMMUNICATION_COMMON_KEY_SETUP_USER_KEY(0xC0),
+        SECURE_COMMUNICATION_COMMON_KEY_SETUP_SERVICE_PROVIDER_KEY(0xC1),
+        SECURE_COMMUNICATION_COMMON_KEY_SWITCHOVER_SETUP_USER_KEY(0xC2),
+        SECURE_COMMUNICATION_COMMON_KEY_SWITCHOVER_SETUP_SERVICE_PROVIDER_KEY(0xC3),
+        SECURE_COMMUNICATION_COMMON_KEY_SERIAL_KEY(0xC4),
+        SELF_NODE_INSTANCE_LIST_PAGE(0xD0),
+        SELF_NODE_CLASS_LIST(0xD2),
+        SELF_NODE_INSTANCE_COUNT(0xD3),
+        SELF_NODE_CLASS_COUNT(0xD4),
+        INSTANCE_CHANGE_CLASS_COUNT(0xD5),
+        SELF_NODE_INSTANCE_LIST_S(0xD6),
+        SELF_NODE_CLASS_LIST_S(0xD7),
+        RELATED_TO_OTHER_NODE_EA_LIST(0xD8),
+        RELATED_TO_OTHER_NODE_EA_COUNT(0xD9),
+        GROUP_BROADCAST_NUMBER(0xDA),;
+
+        public final int code;
+
+        NodeProfile(int code) {
+            this.code = code;
+        }
+
+        public int code() {
+            return code;
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EpcLookupTable.java b/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/EpcLookupTable.java
new file mode 100644 (file)
index 0000000..37c9009
--- /dev/null
@@ -0,0 +1,86 @@
+/**
+ * Copyright (c) 2010-2022 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.echonetlite.internal;
+
+import static org.openhab.binding.echonetlite.internal.HexUtil.hex;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * @author Michael Barker - Initial contribution
+ */
+@NonNullByDefault
+enum EpcLookupTable {
+    INSTANCE;
+
+    private static final int MAX_ENTRIES = 256;
+    private final Epc[][][] lookupTable = new Epc[MAX_ENTRIES][0][0];
+
+    EpcLookupTable() {
+        addLookupTableEntries(lookupTable, EchonetClass.AIRCON_HOMEAC);
+        addLookupTableEntries(lookupTable, EchonetClass.MANAGEMENT_CONTROLLER);
+        addLookupTableEntries(lookupTable, EchonetClass.NODE_PROFILE);
+    }
+
+    public Epc resolve(int groupCode, int classCode, int epcCode) {
+        if (MAX_ENTRIES <= groupCode) {
+            throw new IllegalArgumentException(MAX_ENTRIES + "<= groupCode (" + groupCode + ")");
+        }
+        if (MAX_ENTRIES <= classCode) {
+            throw new IllegalArgumentException(MAX_ENTRIES + "<= classCode (" + classCode + ")");
+        }
+        if (MAX_ENTRIES <= epcCode) {
+            throw new IllegalArgumentException(MAX_ENTRIES + "<= epcCode (" + epcCode + ")");
+        }
+
+        if (0 == lookupTable[groupCode].length) {
+            throw new IllegalArgumentException("groupCode (" + hex(groupCode) + ") has no entries");
+        }
+
+        if (0 == lookupTable[groupCode][classCode].length) {
+            throw new IllegalArgumentException(
+                    "groupCode/classCode (" + hex(groupCode) + "/" + hex(classCode) + ") has no entries");
+        }
+
+        if (null == lookupTable[groupCode][classCode][epcCode]) {
+            throw new IllegalArgumentException("groupCode/classCode (" + hex(groupCode) + "/" + hex(classCode) + "/"
+                    + hex(epcCode) + ") has no entry");
+        }
+
+        return lookupTable[groupCode][classCode][epcCode];
+    }
+
+    private static void addLookupTableEntries(Epc[][][] lookupTable, EchonetClass echonetClass) {
+        final int groupCode = echonetClass.groupCode();
+        final int classCode = echonetClass.classCode();
+
+        if (null == lookupTable[groupCode] || 0 == lookupTable[groupCode].length) {
+            lookupTable[groupCode] = new Epc[MAX_ENTRIES][0];
+        }
+        if (null == lookupTable[groupCode][classCode] || 0 == lookupTable[groupCode][classCode].length) {
+            lookupTable[groupCode][classCode] = new Epc[MAX_ENTRIES];
+        }
+
+        for (Epc value : echonetClass.deviceProperties()) {
+            lookupTable[groupCode][classCode][value.code()] = value;
+        }
+
+        for (Epc value : echonetClass.groupProperties()) {
+            lookupTable[groupCode][classCode][value.code()] = value;
+        }
+
+        for (Epc value : echonetClass.classProperties()) {
+            lookupTable[groupCode][classCode][value.code()] = value;
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/Esv.java b/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/Esv.java
new file mode 100644 (file)
index 0000000..3f22002
--- /dev/null
@@ -0,0 +1,88 @@
+/**
+ * Copyright (c) 2010-2022 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.echonetlite.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * @author Michael Barker - Initial contribution
+ */
+@NonNullByDefault
+public enum Esv {
+    SetI(0x60),
+    SetC(0x61),
+    Get(0x62),
+    INF_REQ(0x63),
+    SetMI(0x64),
+    SetMC(0x65),
+    GetM(0x66),
+    INFM_REQ(0x67),
+    AddMI(0x68),
+    AddMC(0x69),
+    DelMI(0x6a),
+    DelMC(0x6b),
+    CheckM(0x6c),
+    AddMSI(0x6d),
+    AddMSC(0x6e),
+    Set_Res(0x71),
+    Get_Res(0x72),
+    INF(0x73),
+    INFC(0x74),
+    SetM_Res(0x75),
+    GetM_Res(0x76),
+    INFM(0x77),
+    INFMC(0x78),
+    AddM_Res(0x79),
+    INFC_Res(0x7a),
+    DelM_Res(0x7b),
+    CheckM_Res(0x7d),
+    INFMC_Res(0x7d),
+    AddMS_Res(0x7e),
+    SetI_SNA(0x50),
+    SetC_SNA(0x51),
+    Get_SNA(0x52),
+    INF_SNA(0x53),
+    SetMI_SNA(0x54),
+    SetMC_SNA(0x55),
+    GetM_SNA(0x56),
+    INFM_SNA(0x57),
+    AddMI_SNA(0x58),
+    AddMC_SNA(0x59),
+    DelMI_SNA(0x5a),
+    DelMC_SNA(0x5b),
+    CheckM_SNA(0x5c),
+    AddMSI_SNA(0x5d),
+    AddMSC_SNA(0x5e),
+    Unknown(0x00);
+
+    private final byte code;
+
+    Esv(int code) {
+        this.code = (byte) (code & 0xFF);
+    }
+
+    public static Esv forCode(byte b) {
+        final Esv[] values = values();
+        for (Esv value : values) {
+            if (value.code == b) {
+                return value;
+            }
+        }
+
+        throw new IllegalArgumentException("Unable to find Esv for: " + b);
+    }
+
+    public byte code() {
+        return code;
+    }
+}
diff --git a/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/HexUtil.java b/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/HexUtil.java
new file mode 100644 (file)
index 0000000..e215a35
--- /dev/null
@@ -0,0 +1,71 @@
+/**
+ * Copyright (c) 2010-2022 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.echonetlite.internal;
+
+import java.nio.ByteBuffer;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * @author Michael Barker - Initial contribution
+ */
+@NonNullByDefault
+public class HexUtil {
+    public static String hex(ByteBuffer buffer) {
+        return hex(buffer, "[", "]", "0x", ",");
+    }
+
+    public static String hex(final ByteBuffer buffer, final String stringPrefix, final String stringSuffix,
+            final String bytePrefix, final String delimiter) {
+        final StringBuilder sb = new StringBuilder();
+        sb.append(stringPrefix);
+        for (int i = buffer.position(), n = buffer.limit(); i < n; i++) {
+            final int b = buffer.get(i) & 0xFF;
+            final String prefix = b < 0x10 ? "0" : "";
+            sb.append(bytePrefix).append(prefix).append(Integer.toHexString(b)).append(delimiter);
+        }
+        sb.setLength(sb.length() - delimiter.length());
+        sb.append(stringSuffix);
+
+        return sb.toString();
+    }
+
+    public static String hex(int[] array, int offset, int length) {
+        final StringBuilder sb = new StringBuilder();
+        sb.append('[');
+        for (int i = offset; i < length; i++) {
+            final int b = array[i] & 0xFF;
+            hex(sb, b);
+            sb.append(',');
+        }
+        sb.setLength(sb.length() - 1);
+        sb.append(']');
+
+        return sb.toString();
+    }
+
+    private static void hex(final StringBuilder sb, final int b) {
+        final String prefix = b < 0x10 ? "0" : "";
+        sb.append("0x").append(prefix).append(Integer.toHexString(b));
+    }
+
+    public static String hex(final int b) {
+        final StringBuilder sb = new StringBuilder();
+        hex(sb, b);
+        return sb.toString();
+    }
+
+    public static String hex(int[] array) {
+        return hex(array, 0, array.length);
+    }
+}
diff --git a/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/InstanceKey.java b/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/InstanceKey.java
new file mode 100644 (file)
index 0000000..b994636
--- /dev/null
@@ -0,0 +1,62 @@
+/**
+ * Copyright (c) 2010-2022 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.echonetlite.internal;
+
+import static java.util.Objects.requireNonNull;
+import static org.openhab.binding.echonetlite.internal.HexUtil.hex;
+
+import java.net.InetSocketAddress;
+import java.util.Objects;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * @author Michael Barker - Initial contribution
+ */
+@NonNullByDefault
+public class InstanceKey {
+    final InetSocketAddress address;
+    final EchonetClass klass;
+    final int instance;
+
+    public InstanceKey(final InetSocketAddress address, final EchonetClass klass, final int instance) {
+        this.address = requireNonNull(address);
+        this.klass = requireNonNull(klass);
+        this.instance = instance;
+    }
+
+    public String toString() {
+        return "InstanceKey{" + "address=" + address + ", klass=" + klass + ", instance=" + instance + '}';
+    }
+
+    public String representationProperty() {
+        return address.getAddress().getHostAddress() + "_" + hex(klass.groupCode()) + ":" + hex(klass.classCode()) + ":"
+                + hex(instance);
+    }
+
+    public boolean equals(@Nullable final Object o) {
+        if (this == o) {
+            return true;
+        }
+        if (o == null || getClass() != o.getClass()) {
+            return false;
+        }
+        final InstanceKey that = (InstanceKey) o;
+        return instance == that.instance && address.equals(that.address) && klass == that.klass;
+    }
+
+    public int hashCode() {
+        return Objects.hash(address, klass, instance);
+    }
+}
diff --git a/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/LangUtil.java b/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/LangUtil.java
new file mode 100644 (file)
index 0000000..60c7184
--- /dev/null
@@ -0,0 +1,42 @@
+/**
+ * Copyright (c) 2010-2022 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.echonetlite.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * @author Michael Barker - Initial contribution
+ */
+@NonNullByDefault
+public class LangUtil {
+    public static byte b(int i) {
+        return (byte) (i & 0xFF);
+    }
+
+    public static String constantToVariable(CharSequence constant) {
+        final StringBuilder sb = new StringBuilder();
+        boolean shouldCapitalise = false;
+        for (int i = 0, n = constant.length(); i < n; i++) {
+            final char c = constant.charAt(i);
+            if ('_' == c) {
+                shouldCapitalise = true;
+            } else if (shouldCapitalise) {
+                sb.append(Character.toUpperCase(c));
+                shouldCapitalise = false;
+            } else {
+                sb.append(Character.toLowerCase(c));
+            }
+        }
+        return sb.toString();
+    }
+}
diff --git a/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/MonotonicClock.java b/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/MonotonicClock.java
new file mode 100644 (file)
index 0000000..f807a1a
--- /dev/null
@@ -0,0 +1,33 @@
+/**
+ * Copyright (c) 2010-2022 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.echonetlite.internal;
+
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * @author Michael Barker - Initial contribution
+ */
+@NonNullByDefault
+class MonotonicClock {
+    private final long baseTimeNs;
+
+    MonotonicClock() {
+        baseTimeNs = System.nanoTime();
+    }
+
+    long timeMs() {
+        return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - baseTimeNs);
+    }
+}
diff --git a/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/ShortSupplier.java b/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/ShortSupplier.java
new file mode 100644 (file)
index 0000000..d2e8dbf
--- /dev/null
@@ -0,0 +1,24 @@
+/**
+ * Copyright (c) 2010-2022 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.echonetlite.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * @author Michael Barker - Initial contribution
+ */
+@FunctionalInterface
+@NonNullByDefault
+public interface ShortSupplier {
+    short getAsShort();
+}
diff --git a/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/StateCodec.java b/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/StateCodec.java
new file mode 100644 (file)
index 0000000..b4adf84
--- /dev/null
@@ -0,0 +1,216 @@
+/**
+ * Copyright (c) 2010-2022 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.echonetlite.internal;
+
+import static org.openhab.binding.echonetlite.internal.HexUtil.hex;
+import static org.openhab.binding.echonetlite.internal.LangUtil.b;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.library.types.DecimalType;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.library.unit.SIUnits;
+import org.openhab.core.library.unit.Units;
+import org.openhab.core.types.State;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * @author Michael Barker - Initial contribution
+ */
+@NonNullByDefault
+public interface StateCodec extends StateEncode, StateDecode {
+
+    class OnOffCodec implements StateCodec {
+        private final int on;
+        private final int off;
+
+        public OnOffCodec(int on, int off) {
+            this.on = on;
+            this.off = off;
+        }
+
+        public State decodeState(final ByteBuffer edt) {
+            return b(on) == edt.get() ? OnOffType.ON : OnOffType.OFF;
+        }
+
+        public void encodeState(final State state, final ByteBuffer edt) {
+            final OnOffType onOff = (OnOffType) state;
+            edt.put(onOff == OnOffType.ON ? b(on) : b(off));
+        }
+
+        public String itemType() {
+            return "Switch";
+        }
+    }
+
+    enum StandardVersionInformationCodec implements StateDecode {
+
+        INSTANCE;
+
+        public State decodeState(final ByteBuffer edt) {
+            final int pdc = edt.remaining();
+            if (pdc != 4) {
+                return StringType.EMPTY;
+            }
+
+            return new StringType("" + (char) edt.get(edt.position() + 2));
+        }
+
+        public String itemType() {
+            return "String";
+        }
+    }
+
+    enum HexStringCodec implements StateDecode {
+
+        INSTANCE;
+
+        public State decodeState(final ByteBuffer edt) {
+            return new StringType(hex(edt, "", "", "", ""));
+        }
+
+        public String itemType() {
+            return "String";
+        }
+    }
+
+    enum OperatingTimeDecode implements StateDecode {
+        INSTANCE;
+
+        public State decodeState(final ByteBuffer edt) {
+            // Specification isn't explicit about byte order, but seems to be work with testing.
+            edt.order(ByteOrder.BIG_ENDIAN);
+
+            final int b0 = edt.get() & 0xFF;
+            final long time = edt.getInt() & 0xFFFFFFFFL;
+
+            final TimeUnit timeUnit;
+            switch (b0) {
+                case 0x42:
+                    timeUnit = TimeUnit.MINUTES;
+                    break;
+
+                case 0x43:
+                    timeUnit = TimeUnit.HOURS;
+                    break;
+
+                case 0x44:
+                    timeUnit = TimeUnit.DAYS;
+                    break;
+
+                case 0x41:
+                default:
+                    timeUnit = TimeUnit.SECONDS;
+                    break;
+            }
+
+            return new QuantityType<>(timeUnit.toSeconds(time), Units.SECOND);
+        }
+
+        public String itemType() {
+            return "Number:Time";
+        }
+    }
+
+    class Option {
+        final String name;
+        final int value;
+        final StringType state;
+
+        public Option(final String name, final int value) {
+            this.name = name;
+            this.value = value;
+            this.state = new StringType(name);
+        }
+    }
+
+    class OptionCodec implements StateCodec {
+
+        private final Logger logger = LoggerFactory.getLogger(OptionCodec.class);
+        private final Map<String, Option> optionByName = new HashMap<>();
+        private final Option[] optionByValue = new Option[256]; // All options values are single bytes on the wire
+        private final StringType unknown = new StringType("Unknown");
+
+        public OptionCodec(Option... options) {
+            for (Option option : options) {
+                optionByName.put(option.name, option);
+                optionByValue[option.value] = option;
+            }
+        }
+
+        public String itemType() {
+            return "String";
+        }
+
+        public State decodeState(final ByteBuffer edt) {
+            final int value = edt.get() & 0xFF;
+            final Option option = optionByValue[value];
+            return null != option ? option.state : unknown;
+        }
+
+        public void encodeState(final State state, final ByteBuffer edt) {
+            final Option option = optionByName.get(state.toFullString());
+            if (null != option) {
+                edt.put(b(option.value));
+            } else {
+                logger.warn("No option specified for: {}", state);
+            }
+        }
+    }
+
+    enum Decimal8bitCodec implements StateCodec {
+
+        INSTANCE;
+
+        public String itemType() {
+            return "Number";
+        }
+
+        public State decodeState(final ByteBuffer edt) {
+            final int value = edt.get(); // Should expand to typed value (mask excluded)
+            return new DecimalType(value);
+        }
+
+        public void encodeState(final State state, final ByteBuffer edt) {
+            edt.put((byte) (((DecimalType) state).intValue()));
+        }
+    }
+
+    enum Temperature8bitCodec implements StateCodec {
+        INSTANCE;
+
+        public State decodeState(final ByteBuffer edt) {
+            final int value = edt.get();
+            return new QuantityType<>(value, SIUnits.CELSIUS);
+        }
+
+        public String itemType() {
+            return "Number:Temperature";
+        }
+
+        public void encodeState(final State state, final ByteBuffer edt) {
+            final @Nullable QuantityType<?> tempCelsius = ((QuantityType<?>) state).toUnit(SIUnits.CELSIUS);
+            edt.put((byte) (Objects.requireNonNull(tempCelsius).intValue()));
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/StateDecode.java b/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/StateDecode.java
new file mode 100644 (file)
index 0000000..580c810
--- /dev/null
@@ -0,0 +1,28 @@
+/**
+ * Copyright (c) 2010-2022 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.echonetlite.internal;
+
+import java.nio.ByteBuffer;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.types.State;
+
+/**
+ * @author Michael Barker - Initial contribution
+ */
+@NonNullByDefault
+public interface StateDecode {
+    State decodeState(final ByteBuffer edt);
+
+    String itemType();
+}
diff --git a/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/StateEncode.java b/bundles/org.openhab.binding.echonetlite/src/main/java/org/openhab/binding/echonetlite/internal/StateEncode.java
new file mode 100644 (file)
index 0000000..94f15f9
--- /dev/null
@@ -0,0 +1,27 @@
+/**
+ * Copyright (c) 2010-2022 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.echonetlite.internal;
+
+import java.nio.ByteBuffer;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.types.State;
+
+/**
+ * @author Michael Barker - Initial contribution
+ */
+@FunctionalInterface
+@NonNullByDefault
+public interface StateEncode {
+    void encodeState(final State state, final ByteBuffer edt);
+}
diff --git a/bundles/org.openhab.binding.echonetlite/src/main/resources/OH-INF/binding/binding.xml b/bundles/org.openhab.binding.echonetlite/src/main/resources/OH-INF/binding/binding.xml
new file mode 100644 (file)
index 0000000..c69e0af
--- /dev/null
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<binding:binding id="echonetlite" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:binding="https://openhab.org/schemas/binding/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/binding/v1.0.0 https://openhab.org/schemas/binding-1.0.0.xsd">
+
+       <name>EchonetLite Binding</name>
+       <description>This is the binding for EchonetLite.</description>
+
+</binding:binding>
diff --git a/bundles/org.openhab.binding.echonetlite/src/main/resources/OH-INF/i18n/echonetlite.properties b/bundles/org.openhab.binding.echonetlite/src/main/resources/OH-INF/i18n/echonetlite.properties
new file mode 100644 (file)
index 0000000..c1f4762
--- /dev/null
@@ -0,0 +1,251 @@
+# binding
+
+binding.echonetlite.name = EchonetLite Binding
+binding.echonetlite.description = This is the binding for EchonetLite.
+
+# thing types
+
+thing-type.echonetlite.bridge.label = Echonet Bridge
+thing-type.echonetlite.bridge.description = Virtual bridge to ensure that there is only a single binding to the echonet port
+thing-type.echonetlite.device.label = EchonetLite Device
+thing-type.echonetlite.device.description = Device for EchonetLite Binding
+
+# thing types config
+
+thing-type.config.echonetlite.bridge.multicastAddress.label = Discovery/Notification Address
+thing-type.config.echonetlite.bridge.multicastAddress.description = Address used to discover nodes and receive notifications
+thing-type.config.echonetlite.bridge.port.label = Echonet Port
+thing-type.config.echonetlite.bridge.port.description = Port used for echonet messages both outbound and inbound
+thing-type.config.echonetlite.device.classCode.label = Class Code
+thing-type.config.echonetlite.device.classCode.description = Echonet Class Code
+thing-type.config.echonetlite.device.groupCode.label = Group Code
+thing-type.config.echonetlite.device.groupCode.description = Echonet Group Code
+thing-type.config.echonetlite.device.hostname.label = Hostname
+thing-type.config.echonetlite.device.hostname.description = Hostname or IP address of the device
+thing-type.config.echonetlite.device.instance.label = Instance
+thing-type.config.echonetlite.device.instance.description = Echonet Instance
+thing-type.config.echonetlite.device.pollIntervalMs.label = Poll Interval (ms)
+thing-type.config.echonetlite.device.pollIntervalMs.description = Interval in ms between each poll of the device
+thing-type.config.echonetlite.device.port.label = Port
+thing-type.config.echonetlite.device.port.description = Port of the device (usually 3610)
+thing-type.config.echonetlite.device.retryTimeoutMs.label = Retry Timeout (ms)
+thing-type.config.echonetlite.device.retryTimeoutMs.description = Timeout in ms before a message is resent
+
+# channel types
+
+channel-type.echonetlite.airFlowDirectionHorizontal.label = Air Flow Direction Horizontal
+channel-type.echonetlite.airFlowDirectionHorizontal.description = Air Flow Direction Horizontal
+channel-type.echonetlite.airFlowDirectionHorizontal.state.option.XXXOO = XXXOO
+channel-type.echonetlite.airFlowDirectionHorizontal.state.option.OOXXX = OOXXX
+channel-type.echonetlite.airFlowDirectionHorizontal.state.option.XOOOX = XOOOX
+channel-type.echonetlite.airFlowDirectionHorizontal.state.option.OOXOO = OOXOO
+channel-type.echonetlite.airFlowDirectionHorizontal.state.option.XXXXO = XXXXO
+channel-type.echonetlite.airFlowDirectionHorizontal.state.option.XXXOX = XXXOX
+channel-type.echonetlite.airFlowDirectionHorizontal.state.option.XXOXX = XXOXX
+channel-type.echonetlite.airFlowDirectionHorizontal.state.option.XXOXO = XXOXO
+channel-type.echonetlite.airFlowDirectionHorizontal.state.option.XXOOX = XXOOX
+channel-type.echonetlite.airFlowDirectionHorizontal.state.option.XXOOO = XXOOO
+channel-type.echonetlite.airFlowDirectionHorizontal.state.option.XOXXX = XOXXX
+channel-type.echonetlite.airFlowDirectionHorizontal.state.option.XOXXO = XOXXO
+channel-type.echonetlite.airFlowDirectionHorizontal.state.option.XOXOX = XOXOX
+channel-type.echonetlite.airFlowDirectionHorizontal.state.option.XOXOO = XOXOO
+channel-type.echonetlite.airFlowDirectionHorizontal.state.option.XOOXX = XOOXX
+channel-type.echonetlite.airFlowDirectionHorizontal.state.option.XOOXO = XOOXO
+channel-type.echonetlite.airFlowDirectionHorizontal.state.option.XOOOO = XOOOO
+channel-type.echonetlite.airFlowDirectionHorizontal.state.option.OXXXX = OXXXX
+channel-type.echonetlite.airFlowDirectionHorizontal.state.option.OXXXO = OXXXO
+channel-type.echonetlite.airFlowDirectionHorizontal.state.option.OXXOX = OXXOX
+channel-type.echonetlite.airFlowDirectionHorizontal.state.option.OXXOO = OXXOO
+channel-type.echonetlite.airFlowDirectionHorizontal.state.option.OXOXX = OXOXX
+channel-type.echonetlite.airFlowDirectionHorizontal.state.option.OXOXO = OXOXO
+channel-type.echonetlite.airFlowDirectionHorizontal.state.option.OXOOX = OXOOX
+channel-type.echonetlite.airFlowDirectionHorizontal.state.option.OXOOO = OXOOO
+channel-type.echonetlite.airFlowDirectionHorizontal.state.option.OOXXO = OOXXO
+channel-type.echonetlite.airFlowDirectionHorizontal.state.option.OOXOX = OOXOX
+channel-type.echonetlite.airFlowDirectionHorizontal.state.option.OOOXX = OOOXX
+channel-type.echonetlite.airFlowDirectionHorizontal.state.option.OOOXO = OOOXO
+channel-type.echonetlite.airFlowDirectionHorizontal.state.option.OOOOX = OOOOX
+channel-type.echonetlite.airFlowDirectionHorizontal.state.option.OOOOO = OOOOO
+channel-type.echonetlite.airFlowDirectionVertical.label = Air Flow Direction Vertical
+channel-type.echonetlite.airFlowDirectionVertical.description = Air Flow Direction Vertical
+channel-type.echonetlite.airFlowDirectionVertical.state.option.Uppermost = Uppermost
+channel-type.echonetlite.airFlowDirectionVertical.state.option.Lowermost = Lowermost
+channel-type.echonetlite.airFlowDirectionVertical.state.option.Mid-uppermost = Mid-uppermost
+channel-type.echonetlite.airFlowDirectionVertical.state.option.Mid-lowermost = Mid-lowermost
+channel-type.echonetlite.airFlowDirectionVertical.state.option.Central = Central
+channel-type.echonetlite.airFlowRate.label = Air Flow Rate
+channel-type.echonetlite.airFlowRate.description = Air Flow Rate
+channel-type.echonetlite.airFlowRate.state.option.Auto = Auto
+channel-type.echonetlite.airFlowRate.state.option.Rate\ 1 = Rate 1
+channel-type.echonetlite.airFlowRate.state.option.Rate\ 2 = Rate 2
+channel-type.echonetlite.airFlowRate.state.option.Rate\ 3 = Rate 3
+channel-type.echonetlite.airFlowRate.state.option.Rate\ 4 = Rate 4
+channel-type.echonetlite.airFlowRate.state.option.Rate\ 5 = Rate 5
+channel-type.echonetlite.airFlowRate.state.option.Rate\ 6 = Rate 6
+channel-type.echonetlite.airFlowRate.state.option.Rate\ 7 = Rate 7
+channel-type.echonetlite.airFlowRate.state.option.Rate\ 8 = Rate 8
+channel-type.echonetlite.automaticControlOfAirFlowDirection.label = Automatic Air Flow Direction
+channel-type.echonetlite.automaticControlOfAirFlowDirection.description = The type of automatic control applied to the air flow direction, if any
+channel-type.echonetlite.automaticControlOfAirFlowDirection.state.option.Automatic = Automatic
+channel-type.echonetlite.automaticControlOfAirFlowDirection.state.option.Non-automatic = Non-automatic
+channel-type.echonetlite.automaticControlOfAirFlowDirection.state.option.Automatic\ (vertical) = Automatic (vertical)
+channel-type.echonetlite.automaticControlOfAirFlowDirection.state.option.Automatic\ (horizontal) = Automatic (horizontal)
+channel-type.echonetlite.automaticSwingOfAirFlow.label = Automatic Swing Of Air Flow
+channel-type.echonetlite.automaticSwingOfAirFlow.description = Automatic Swing Of Air Flow
+channel-type.echonetlite.automaticSwingOfAirFlow.state.option.Not\ used = Not used
+channel-type.echonetlite.automaticSwingOfAirFlow.state.option.Used\ (vertical) = Used (vertical)
+channel-type.echonetlite.automaticSwingOfAirFlow.state.option.Used\ (horizontal) = Used (horizontal)
+channel-type.echonetlite.automaticSwingOfAirFlow.state.option.Used\ (vertical\ and\ horizontal) = Used (vertical and horizontal)
+channel-type.echonetlite.businessFacilityCode.label = Business Facility Code
+channel-type.echonetlite.businessFacilityCode.description = Business Facility Code
+channel-type.echonetlite.cumulativeOperatingTime.label = Cumulative Operating Time
+channel-type.echonetlite.cumulativeOperatingTime.description = Cumulative time the unit has been operating in seconds
+channel-type.echonetlite.faultDescription.label = Fault Description
+channel-type.echonetlite.faultDescription.description = Fault Description
+channel-type.echonetlite.faultStatus.label = Fault Status
+channel-type.echonetlite.faultStatus.description = Fault Status
+channel-type.echonetlite.identificationNumber.label = Identification Number
+channel-type.echonetlite.identificationNumber.description = Identification Number
+channel-type.echonetlite.installationLocation.label = Installation Location
+channel-type.echonetlite.installationLocation.description = Installation Location
+channel-type.echonetlite.installationLocation.state.option.Not\ specified = Not specified
+channel-type.echonetlite.installationLocation.state.option.Living\ Room = Living Room
+channel-type.echonetlite.installationLocation.state.option.Living\ Room\ 1 = Living Room 1
+channel-type.echonetlite.installationLocation.state.option.Living\ Room\ 2 = Living Room 2
+channel-type.echonetlite.installationLocation.state.option.Living\ Room\ 3 = Living Room 3
+channel-type.echonetlite.installationLocation.state.option.Living\ Room\ 4 = Living Room 4
+channel-type.echonetlite.installationLocation.state.option.Living\ Room\ 5 = Living Room 5
+channel-type.echonetlite.installationLocation.state.option.Living\ Room\ 6 = Living Room 6
+channel-type.echonetlite.installationLocation.state.option.Living\ Room\ 7 = Living Room 7
+channel-type.echonetlite.installationLocation.state.option.Dining\ Room = Dining Room
+channel-type.echonetlite.installationLocation.state.option.Dining\ Room\ 1 = Dining Room 1
+channel-type.echonetlite.installationLocation.state.option.Dining\ Room\ 2 = Dining Room 2
+channel-type.echonetlite.installationLocation.state.option.Dining\ Room\ 3 = Dining Room 3
+channel-type.echonetlite.installationLocation.state.option.Dining\ Room\ 4 = Dining Room 4
+channel-type.echonetlite.installationLocation.state.option.Dining\ Room\ 5 = Dining Room 5
+channel-type.echonetlite.installationLocation.state.option.Dining\ Room\ 6 = Dining Room 6
+channel-type.echonetlite.installationLocation.state.option.Dining\ Room\ 7 = Dining Room 7
+channel-type.echonetlite.installationLocation.state.option.Kitchen = "Kitchen"
+channel-type.echonetlite.installationLocation.state.option.Kitchen\ 1 = Kitchen 1
+channel-type.echonetlite.installationLocation.state.option.Kitchen\ 2 = Kitchen 2
+channel-type.echonetlite.installationLocation.state.option.Kitchen\ 3 = Kitchen 3
+channel-type.echonetlite.installationLocation.state.option.Kitchen\ 4 = Kitchen 4
+channel-type.echonetlite.installationLocation.state.option.Kitchen\ 5 = Kitchen 5
+channel-type.echonetlite.installationLocation.state.option.Kitchen\ 6 = Kitchen 6
+channel-type.echonetlite.installationLocation.state.option.Kitchen\ 7 = Kitchen 7
+channel-type.echonetlite.installationLocation.state.option.Lavatory = "Lavatory"
+channel-type.echonetlite.installationLocation.state.option.Lavatory\ 1 = Lavatory 1
+channel-type.echonetlite.installationLocation.state.option.Lavatory\ 2 = Lavatory 2
+channel-type.echonetlite.installationLocation.state.option.Lavatory\ 3 = Lavatory 3
+channel-type.echonetlite.installationLocation.state.option.Lavatory\ 4 = Lavatory 4
+channel-type.echonetlite.installationLocation.state.option.Lavatory\ 5 = Lavatory 5
+channel-type.echonetlite.installationLocation.state.option.Lavatory\ 6 = Lavatory 6
+channel-type.echonetlite.installationLocation.state.option.Lavatory\ 7 = Lavatory 7
+channel-type.echonetlite.installationLocation.state.option.Washroom/changing\ room = Washroom/changing room
+channel-type.echonetlite.installationLocation.state.option.Washroom/changing\ room\ 1 = Washroom/changing room 1
+channel-type.echonetlite.installationLocation.state.option.Washroom/changing\ room\ 2 = Washroom/changing room 2
+channel-type.echonetlite.installationLocation.state.option.Washroom/changing\ room\ 3 = Washroom/changing room 3
+channel-type.echonetlite.installationLocation.state.option.Washroom/changing\ room\ 4 = Washroom/changing room 4
+channel-type.echonetlite.installationLocation.state.option.Washroom/changing\ room\ 5 = Washroom/changing room 5
+channel-type.echonetlite.installationLocation.state.option.Washroom/changing\ room\ 6 = Washroom/changing room 6
+channel-type.echonetlite.installationLocation.state.option.Washroom/changing\ room\ 7 = Washroom/changing room 7
+channel-type.echonetlite.installationLocation.state.option.Passageway = "Passageway"
+channel-type.echonetlite.installationLocation.state.option.Passageway\ 1 = Passageway 1
+channel-type.echonetlite.installationLocation.state.option.Passageway\ 2 = Passageway 2
+channel-type.echonetlite.installationLocation.state.option.Passageway\ 3 = Passageway 3
+channel-type.echonetlite.installationLocation.state.option.Passageway\ 4 = Passageway 4
+channel-type.echonetlite.installationLocation.state.option.Passageway\ 5 = Passageway 5
+channel-type.echonetlite.installationLocation.state.option.Passageway\ 6 = Passageway 6
+channel-type.echonetlite.installationLocation.state.option.Passageway\ 7 = Passageway 7
+channel-type.echonetlite.installationLocation.state.option.Room = "Room"
+channel-type.echonetlite.installationLocation.state.option.Room\ 1 = Room 1
+channel-type.echonetlite.installationLocation.state.option.Room\ 2 = Room 2
+channel-type.echonetlite.installationLocation.state.option.Room\ 3 = Room 3
+channel-type.echonetlite.installationLocation.state.option.Room\ 4 = Room 4
+channel-type.echonetlite.installationLocation.state.option.Room\ 5 = Room 5
+channel-type.echonetlite.installationLocation.state.option.Room\ 6 = Room 6
+channel-type.echonetlite.installationLocation.state.option.Room\ 7 = Room 7
+channel-type.echonetlite.installationLocation.state.option.Stairway = "Stairway"
+channel-type.echonetlite.installationLocation.state.option.Stairway\ 1 = Stairway 1
+channel-type.echonetlite.installationLocation.state.option.Stairway\ 2 = Stairway 2
+channel-type.echonetlite.installationLocation.state.option.Stairway\ 3 = Stairway 3
+channel-type.echonetlite.installationLocation.state.option.Stairway\ 4 = Stairway 4
+channel-type.echonetlite.installationLocation.state.option.Stairway\ 5 = Stairway 5
+channel-type.echonetlite.installationLocation.state.option.Stairway\ 6 = Stairway 6
+channel-type.echonetlite.installationLocation.state.option.Stairway\ 7 = Stairway 7
+channel-type.echonetlite.installationLocation.state.option.Front\ door = Front door
+channel-type.echonetlite.installationLocation.state.option.Front\ door\ 1 = Front door 1
+channel-type.echonetlite.installationLocation.state.option.Front\ door\ 2 = Front door 2
+channel-type.echonetlite.installationLocation.state.option.Front\ door\ 3 = Front door 3
+channel-type.echonetlite.installationLocation.state.option.Front\ door\ 4 = Front door 4
+channel-type.echonetlite.installationLocation.state.option.Front\ door\ 5 = Front door 5
+channel-type.echonetlite.installationLocation.state.option.Front\ door\ 6 = Front door 6
+channel-type.echonetlite.installationLocation.state.option.Front\ door\ 7 = Front door 7
+channel-type.echonetlite.installationLocation.state.option.Storeroom = "Storeroom"
+channel-type.echonetlite.installationLocation.state.option.Storeroom\ 1 = Storeroom 1
+channel-type.echonetlite.installationLocation.state.option.Storeroom\ 2 = Storeroom 2
+channel-type.echonetlite.installationLocation.state.option.Storeroom\ 3 = Storeroom 3
+channel-type.echonetlite.installationLocation.state.option.Storeroom\ 4 = Storeroom 4
+channel-type.echonetlite.installationLocation.state.option.Storeroom\ 5 = Storeroom 5
+channel-type.echonetlite.installationLocation.state.option.Storeroom\ 6 = Storeroom 6
+channel-type.echonetlite.installationLocation.state.option.Storeroom\ 7 = Storeroom 7
+channel-type.echonetlite.installationLocation.state.option.Garden/perimeter = Garden/perimeter
+channel-type.echonetlite.installationLocation.state.option.Garden/perimeter\ 1 = Garden/perimeter 1
+channel-type.echonetlite.installationLocation.state.option.Garden/perimeter\ 2 = Garden/perimeter 2
+channel-type.echonetlite.installationLocation.state.option.Garden/perimeter\ 3 = Garden/perimeter 3
+channel-type.echonetlite.installationLocation.state.option.Garden/perimeter\ 4 = Garden/perimeter 4
+channel-type.echonetlite.installationLocation.state.option.Garden/perimeter\ 5 = Garden/perimeter 5
+channel-type.echonetlite.installationLocation.state.option.Garden/perimeter\ 6 = Garden/perimeter 6
+channel-type.echonetlite.installationLocation.state.option.Garden/perimeter\ 7 = Garden/perimeter 7
+channel-type.echonetlite.installationLocation.state.option.Garage = "Garage"
+channel-type.echonetlite.installationLocation.state.option.Garage\ 1 = Garage 1
+channel-type.echonetlite.installationLocation.state.option.Garage\ 2 = Garage 2
+channel-type.echonetlite.installationLocation.state.option.Garage\ 3 = Garage 3
+channel-type.echonetlite.installationLocation.state.option.Garage\ 4 = Garage 4
+channel-type.echonetlite.installationLocation.state.option.Garage\ 5 = Garage 5
+channel-type.echonetlite.installationLocation.state.option.Garage\ 6 = Garage 6
+channel-type.echonetlite.installationLocation.state.option.Garage\ 7 = Garage 7
+channel-type.echonetlite.installationLocation.state.option.Veranda/balcony = Veranda/balcony
+channel-type.echonetlite.installationLocation.state.option.Veranda/balcony\ 1 = Veranda/balcony 1
+channel-type.echonetlite.installationLocation.state.option.Veranda/balcony\ 2 = Veranda/balcony 2
+channel-type.echonetlite.installationLocation.state.option.Veranda/balcony\ 3 = Veranda/balcony 3
+channel-type.echonetlite.installationLocation.state.option.Veranda/balcony\ 4 = Veranda/balcony 4
+channel-type.echonetlite.installationLocation.state.option.Veranda/balcony\ 5 = Veranda/balcony 5
+channel-type.echonetlite.installationLocation.state.option.Veranda/balcony\ 6 = Veranda/balcony 6
+channel-type.echonetlite.installationLocation.state.option.Veranda/balcony\ 7 = Veranda/balcony 7
+channel-type.echonetlite.installationLocation.state.option.Others = "Others"
+channel-type.echonetlite.installationLocation.state.option.Others\ 1 = Others 1
+channel-type.echonetlite.installationLocation.state.option.Others\ 2 = Others 2
+channel-type.echonetlite.installationLocation.state.option.Others\ 3 = Others 3
+channel-type.echonetlite.installationLocation.state.option.Others\ 4 = Others 4
+channel-type.echonetlite.installationLocation.state.option.Others\ 5 = Others 5
+channel-type.echonetlite.installationLocation.state.option.Others\ 6 = Others 6
+channel-type.echonetlite.installationLocation.state.option.Others\ 7 = Others 7
+channel-type.echonetlite.manufacturerCode.label = Manufacturer Code
+channel-type.echonetlite.manufacturerCode.description = Manufacturer Code
+channel-type.echonetlite.manufacturerFaultCode.label = Manufacturer Fault Code
+channel-type.echonetlite.manufacturerFaultCode.description = Manufacturer Fault Code
+channel-type.echonetlite.measuredOutdoorTemperature.label = Measured Outdoor Temperature
+channel-type.echonetlite.measuredOutdoorTemperature.description = Measured Outdoor Temperature
+channel-type.echonetlite.measuredRoomTemperature.label = Measured Room Temperature
+channel-type.echonetlite.measuredRoomTemperature.description = Measured Room Temperature
+channel-type.echonetlite.operationMode.label = Operation Mode
+channel-type.echonetlite.operationMode.description = The current mode for the Home AC unit (heating, cooling, etc.)
+channel-type.echonetlite.operationMode.state.option.Automatic = Automatic
+channel-type.echonetlite.operationMode.state.option.Cooling = Cooling
+channel-type.echonetlite.operationMode.state.option.Heating = Heating
+channel-type.echonetlite.operationMode.state.option.Dry = Dry
+channel-type.echonetlite.operationMode.state.option.Fan = Fan
+channel-type.echonetlite.operationMode.state.option.Other = Other
+channel-type.echonetlite.operationStatus.label = Operation Status
+channel-type.echonetlite.operationStatus.description = Operation Status
+channel-type.echonetlite.powerSavingOperationSetting.label = Power Saving
+channel-type.echonetlite.powerSavingOperationSetting.description = Controls whether the unit is in power saving operation or not
+channel-type.echonetlite.setTemperature.label = Set Temperature
+channel-type.echonetlite.setTemperature.description = Desired target room temperature
+channel-type.echonetlite.standardVersionInformation.label = Standard Version Information
+channel-type.echonetlite.standardVersionInformation.description = Standard Version Information
+
+# thing status descriptions
+
+offline.conf-error.null-bridge-handler = Bridge is null
diff --git a/bundles/org.openhab.binding.echonetlite/src/main/resources/OH-INF/thing/channel-types.xml b/bundles/org.openhab.binding.echonetlite/src/main/resources/OH-INF/thing/channel-types.xml
new file mode 100644 (file)
index 0000000..84da5ee
--- /dev/null
@@ -0,0 +1,378 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="echonetlite"
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+       <channel-type id="operationStatus">
+               <item-type>Switch</item-type>
+               <label>Operation Status</label>
+               <description>Operation Status</description>
+               <category>Switch</category>
+       </channel-type>
+
+       <channel-type id="installationLocation">
+               <item-type>String</item-type>
+               <label>Installation Location</label>
+               <description>Installation Location</description>
+               <category>Text</category>
+               <state>
+                       <options>
+                               <option value="Not specified">Not specified</option>
+
+                               <option value="Living Room">Living Room</option>
+                               <option value="Living Room 1">Living Room 1</option>
+                               <option value="Living Room 2">Living Room 2</option>
+                               <option value="Living Room 3">Living Room 3</option>
+                               <option value="Living Room 4">Living Room 4</option>
+                               <option value="Living Room 5">Living Room 5</option>
+                               <option value="Living Room 6">Living Room 6</option>
+                               <option value="Living Room 7">Living Room 7</option>
+
+                               <option value="Dining Room">Dining Room</option>
+                               <option value="Dining Room 1">Dining Room 1</option>
+                               <option value="Dining Room 2">Dining Room 2</option>
+                               <option value="Dining Room 3">Dining Room 3</option>
+                               <option value="Dining Room 4">Dining Room 4</option>
+                               <option value="Dining Room 5">Dining Room 5</option>
+                               <option value="Dining Room 6">Dining Room 6</option>
+                               <option value="Dining Room 7">Dining Room 7</option>
+
+                               <option value="Kitchen">"Kitchen"</option>
+                               <option value="Kitchen 1">Kitchen 1</option>
+                               <option value="Kitchen 2">Kitchen 2</option>
+                               <option value="Kitchen 3">Kitchen 3</option>
+                               <option value="Kitchen 4">Kitchen 4</option>
+                               <option value="Kitchen 5">Kitchen 5</option>
+                               <option value="Kitchen 6">Kitchen 6</option>
+                               <option value="Kitchen 7">Kitchen 7</option>
+
+                               <option value="Lavatory">"Lavatory"</option>
+                               <option value="Lavatory 1">Lavatory 1</option>
+                               <option value="Lavatory 2">Lavatory 2</option>
+                               <option value="Lavatory 3">Lavatory 3</option>
+                               <option value="Lavatory 4">Lavatory 4</option>
+                               <option value="Lavatory 5">Lavatory 5</option>
+                               <option value="Lavatory 6">Lavatory 6</option>
+                               <option value="Lavatory 7">Lavatory 7</option>
+
+                               <option value="Washroom/changing room">Washroom/changing room</option>
+                               <option value="Washroom/changing room 1">Washroom/changing room 1</option>
+                               <option value="Washroom/changing room 2">Washroom/changing room 2</option>
+                               <option value="Washroom/changing room 3">Washroom/changing room 3</option>
+                               <option value="Washroom/changing room 4">Washroom/changing room 4</option>
+                               <option value="Washroom/changing room 5">Washroom/changing room 5</option>
+                               <option value="Washroom/changing room 6">Washroom/changing room 6</option>
+                               <option value="Washroom/changing room 7">Washroom/changing room 7</option>
+
+                               <option value="Passageway">"Passageway"</option>
+                               <option value="Passageway 1">Passageway 1</option>
+                               <option value="Passageway 2">Passageway 2</option>
+                               <option value="Passageway 3">Passageway 3</option>
+                               <option value="Passageway 4">Passageway 4</option>
+                               <option value="Passageway 5">Passageway 5</option>
+                               <option value="Passageway 6">Passageway 6</option>
+                               <option value="Passageway 7">Passageway 7</option>
+
+                               <option value="Room">"Room"</option>
+                               <option value="Room 1">Room 1</option>
+                               <option value="Room 2">Room 2</option>
+                               <option value="Room 3">Room 3</option>
+                               <option value="Room 4">Room 4</option>
+                               <option value="Room 5">Room 5</option>
+                               <option value="Room 6">Room 6</option>
+                               <option value="Room 7">Room 7</option>
+
+                               <option value="Stairway">"Stairway"</option>
+                               <option value="Stairway 1">Stairway 1</option>
+                               <option value="Stairway 2">Stairway 2</option>
+                               <option value="Stairway 3">Stairway 3</option>
+                               <option value="Stairway 4">Stairway 4</option>
+                               <option value="Stairway 5">Stairway 5</option>
+                               <option value="Stairway 6">Stairway 6</option>
+                               <option value="Stairway 7">Stairway 7</option>
+
+                               <option value="Front door">Front door</option>
+                               <option value="Front door 1">Front door 1</option>
+                               <option value="Front door 2">Front door 2</option>
+                               <option value="Front door 3">Front door 3</option>
+                               <option value="Front door 4">Front door 4</option>
+                               <option value="Front door 5">Front door 5</option>
+                               <option value="Front door 6">Front door 6</option>
+                               <option value="Front door 7">Front door 7</option>
+
+                               <option value="Storeroom">"Storeroom"</option>
+                               <option value="Storeroom 1">Storeroom 1</option>
+                               <option value="Storeroom 2">Storeroom 2</option>
+                               <option value="Storeroom 3">Storeroom 3</option>
+                               <option value="Storeroom 4">Storeroom 4</option>
+                               <option value="Storeroom 5">Storeroom 5</option>
+                               <option value="Storeroom 6">Storeroom 6</option>
+                               <option value="Storeroom 7">Storeroom 7</option>
+
+                               <option value="Garden/perimeter">Garden/perimeter</option>
+                               <option value="Garden/perimeter 1">Garden/perimeter 1</option>
+                               <option value="Garden/perimeter 2">Garden/perimeter 2</option>
+                               <option value="Garden/perimeter 3">Garden/perimeter 3</option>
+                               <option value="Garden/perimeter 4">Garden/perimeter 4</option>
+                               <option value="Garden/perimeter 5">Garden/perimeter 5</option>
+                               <option value="Garden/perimeter 6">Garden/perimeter 6</option>
+                               <option value="Garden/perimeter 7">Garden/perimeter 7</option>
+
+                               <option value="Garage">"Garage"</option>
+                               <option value="Garage 1">Garage 1</option>
+                               <option value="Garage 2">Garage 2</option>
+                               <option value="Garage 3">Garage 3</option>
+                               <option value="Garage 4">Garage 4</option>
+                               <option value="Garage 5">Garage 5</option>
+                               <option value="Garage 6">Garage 6</option>
+                               <option value="Garage 7">Garage 7</option>
+
+                               <option value="Veranda/balcony">Veranda/balcony</option>
+                               <option value="Veranda/balcony 1">Veranda/balcony 1</option>
+                               <option value="Veranda/balcony 2">Veranda/balcony 2</option>
+                               <option value="Veranda/balcony 3">Veranda/balcony 3</option>
+                               <option value="Veranda/balcony 4">Veranda/balcony 4</option>
+                               <option value="Veranda/balcony 5">Veranda/balcony 5</option>
+                               <option value="Veranda/balcony 6">Veranda/balcony 6</option>
+                               <option value="Veranda/balcony 7">Veranda/balcony 7</option>
+
+                               <option value="Others">"Others"</option>
+                               <option value="Others 1">Others 1</option>
+                               <option value="Others 2">Others 2</option>
+                               <option value="Others 3">Others 3</option>
+                               <option value="Others 4">Others 4</option>
+                               <option value="Others 5">Others 5</option>
+                               <option value="Others 6">Others 6</option>
+                               <option value="Others 7">Others 7</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="standardVersionInformation">
+               <item-type>String</item-type>
+               <label>Standard Version Information</label>
+               <description>Standard Version Information</description>
+               <category>Text</category>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="identificationNumber">
+               <item-type>String</item-type>
+               <label>Identification Number</label>
+               <description>Identification Number</description>
+               <category>Text</category>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="manufacturerFaultCode">
+               <item-type>String</item-type>
+               <label>Manufacturer Fault Code</label>
+               <description>Manufacturer Fault Code</description>
+               <category>Text</category>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="faultStatus">
+               <item-type>Switch</item-type>
+               <label>Fault Status</label>
+               <description>Fault Status</description>
+               <category>Alarm</category>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="faultDescription">
+               <item-type>String</item-type>
+               <label>Fault Description</label>
+               <description>Fault Description</description>
+               <category>Text</category>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="manufacturerCode">
+               <item-type>String</item-type>
+               <label>Manufacturer Code</label>
+               <description>Manufacturer Code</description>
+               <category>Text</category>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="businessFacilityCode">
+               <item-type>String</item-type>
+               <label>Business Facility Code</label>
+               <description>Business Facility Code</description>
+               <category>Text</category>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="powerSavingOperationSetting">
+               <item-type>Switch</item-type>
+               <label>Power Saving</label>
+               <description>Controls whether the unit is in power saving operation or not</description>
+               <category>Switch</category>
+       </channel-type>
+
+       <channel-type id="cumulativeOperatingTime">
+               <item-type>Number:Time</item-type>
+               <label>Cumulative Operating Time</label>
+               <description>Cumulative time the unit has been operating in seconds</description>
+               <category>Time</category>
+       </channel-type>
+
+       <channel-type id="airFlowRate">
+               <item-type>String</item-type>
+               <label>Air Flow Rate</label>
+               <description>Air Flow Rate</description>
+               <category>Flow</category>
+               <state>
+                       <options>
+                               <option value="Auto">Auto</option>
+                               <option value="Rate 1">Rate 1</option>
+                               <option value="Rate 2">Rate 2</option>
+                               <option value="Rate 3">Rate 3</option>
+                               <option value="Rate 4">Rate 4</option>
+                               <option value="Rate 5">Rate 5</option>
+                               <option value="Rate 6">Rate 6</option>
+                               <option value="Rate 7">Rate 7</option>
+                               <option value="Rate 8">Rate 8</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="automaticControlOfAirFlowDirection">
+               <item-type>String</item-type>
+               <label>Automatic Air Flow Direction</label>
+               <description>The type of automatic control applied to the air flow direction, if any</description>
+               <state>
+                       <options>
+                               <option value="Automatic">Automatic</option>
+                               <option value="Non-automatic">Non-automatic</option>
+                               <option value="Automatic (vertical)">Automatic (vertical)</option>
+                               <option value="Automatic (horizontal)">Automatic (horizontal)</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="automaticSwingOfAirFlow">
+               <item-type>String</item-type>
+               <label>Automatic Swing Of Air Flow</label>
+               <description>Automatic Swing Of Air Flow</description>
+               <state>
+                       <options>
+                               <option value="Not used">Not used</option>
+                               <option value="Used (vertical)">Used (vertical)</option>
+                               <option value="Used (horizontal)">Used (horizontal)</option>
+                               <option value="Used (vertical and horizontal)">Used (vertical and horizontal)</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="airFlowDirectionVertical">
+               <item-type>String</item-type>
+               <label>Air Flow Direction Vertical</label>
+               <description>Air Flow Direction Vertical</description>
+               <state>
+                       <options>
+                               <option value="Uppermost">Uppermost</option>
+                               <option value="Lowermost">Lowermost</option>
+                               <option value="Mid-uppermost">Mid-uppermost</option>
+                               <option value="Mid-lowermost">Mid-lowermost</option>
+                               <option value="Central">Central</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="airFlowDirectionHorizontal">
+               <item-type>String</item-type>
+               <label>Air Flow Direction Horizontal</label>
+               <description>Air Flow Direction Horizontal</description>
+               <state>
+                       <options>
+                               <option value="XXXOO">XXXOO</option>
+                               <option value="OOXXX">OOXXX</option>
+                               <option value="XOOOX">XOOOX</option>
+                               <option value="OOXOO">OOXOO</option>
+                               <option value="XXXXO">XXXXO</option>
+                               <option value="XXXOX">XXXOX</option>
+                               <option value="XXOXX">XXOXX</option>
+                               <option value="XXOXO">XXOXO</option>
+                               <option value="XXOOX">XXOOX</option>
+                               <option value="XXOOO">XXOOO</option>
+                               <option value="XOXXX">XOXXX</option>
+                               <option value="XOXXO">XOXXO</option>
+                               <option value="XOXOX">XOXOX</option>
+                               <option value="XOXOO">XOXOO</option>
+                               <option value="XOOXX">XOOXX</option>
+                               <option value="XOOXO">XOOXO</option>
+                               <option value="XOOOO">XOOOO</option>
+                               <option value="OXXXX">OXXXX</option>
+                               <option value="OXXXO">OXXXO</option>
+                               <option value="OXXOX">OXXOX</option>
+                               <option value="OXXOO">OXXOO</option>
+                               <option value="OXOXX">OXOXX</option>
+                               <option value="OXOXO">OXOXO</option>
+                               <option value="OXOOX">OXOOX</option>
+                               <option value="OXOOO">OXOOO</option>
+                               <option value="OOXXO">OOXXO</option>
+                               <option value="OOXOX">OOXOX</option>
+                               <option value="OOOXX">OOOXX</option>
+                               <option value="OOOXO">OOOXO</option>
+                               <option value="OOOOX">OOOOX</option>
+                               <option value="OOOOO">OOOOO</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="operationMode">
+               <item-type>String</item-type>
+               <label>Operation Mode</label>
+               <description>The current mode for the Home AC unit (heating, cooling, etc.)</description>
+               <state>
+                       <options>
+                               <option value="Automatic">Automatic</option>
+                               <option value="Cooling">Cooling</option>
+                               <option value="Heating">Heating</option>
+                               <option value="Dry">Dry</option>
+                               <option value="Fan">Fan</option>
+                               <option value="Other">Other</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="setTemperature">
+               <item-type>Number:Temperature</item-type>
+               <label>Set Temperature</label>
+               <description>Desired target room temperature</description>
+               <category>Temperature</category>
+               <tags>
+                       <tag>Setpoint</tag>
+                       <tag>Temperature</tag>
+               </tags>
+               <state min="0" max="50" pattern="%d %unit%" readOnly="false"/>
+       </channel-type>
+
+       <channel-type id="measuredRoomTemperature">
+               <item-type>Number:Temperature</item-type>
+               <label>Measured Room Temperature</label>
+               <description>Measured Room Temperature</description>
+               <category>Temperature</category>
+               <tags>
+                       <tag>Measurement</tag>
+                       <tag>Temperature</tag>
+               </tags>
+               <state min="-127" max="125" pattern="%d %unit%" readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="measuredOutdoorTemperature">
+               <item-type>Number:Temperature</item-type>
+               <label>Measured Outdoor Temperature</label>
+               <description>Measured Outdoor Temperature</description>
+               <category>Temperature</category>
+               <tags>
+                       <tag>Measurement</tag>
+                       <tag>Temperature</tag>
+               </tags>
+               <state min="-127" max="125" pattern="%d %unit%" readOnly="true"/>
+       </channel-type>
+
+</thing:thing-descriptions>
diff --git a/bundles/org.openhab.binding.echonetlite/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.echonetlite/src/main/resources/OH-INF/thing/thing-types.xml
new file mode 100644 (file)
index 0000000..3b90dea
--- /dev/null
@@ -0,0 +1,73 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="echonetlite"
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+       <bridge-type id="bridge">
+               <label>Echonet Bridge</label>
+               <description>Virtual bridge to ensure that there is only a single binding to the echonet port</description>
+
+               <representation-property>port</representation-property>
+
+               <config-description>
+                       <parameter name="multicastAddress" type="text" required="true">
+                               <context>network-address</context>
+                               <label>Discovery/Notification Address</label>
+                               <description>Address used to discover nodes and receive notifications</description>
+                               <default>224.0.23.0</default>
+                       </parameter>
+                       <parameter name="port" type="integer" required="true">
+                               <label>Echonet Port</label>
+                               <description>Port used for echonet messages both outbound and inbound</description>
+                               <default>3610</default>
+                       </parameter>
+               </config-description>
+       </bridge-type>
+
+       <thing-type id="device">
+               <supported-bridge-type-refs>
+                       <bridge-type-ref id="bridge"/>
+               </supported-bridge-type-refs>
+
+               <label>EchonetLite Device</label>
+               <description>Device for EchonetLite Binding</description>
+               <representation-property>instanceKey</representation-property>
+
+               <config-description>
+                       <parameter name="hostname" type="text" required="true">
+                               <context>network-address</context>
+                               <label>Hostname</label>
+                               <description>Hostname or IP address of the device</description>
+                       </parameter>
+                       <parameter name="port" type="integer" required="true">
+                               <default>3610</default>
+                               <label>Port</label>
+                               <description>Port of the device (usually 3610)</description>
+                       </parameter>
+                       <parameter name="groupCode" type="integer" required="true">
+                               <label>Group Code</label>
+                               <description>Echonet Group Code</description>
+                       </parameter>
+                       <parameter name="classCode" type="integer" required="true">
+                               <label>Class Code</label>
+                               <description>Echonet Class Code</description>
+                       </parameter>
+                       <parameter name="instance" type="integer" required="true">
+                               <label>Instance</label>
+                               <description>Echonet Instance</description>
+                       </parameter>
+                       <parameter name="pollIntervalMs" type="integer" required="true">
+                               <default>30000</default>
+                               <label>Poll Interval (ms)</label>
+                               <description>Interval in ms between each poll of the device</description>
+                       </parameter>
+                       <parameter name="retryTimeoutMs" type="integer" required="true">
+                               <default>2000</default>
+                               <label>Retry Timeout (ms)</label>
+                               <description>Timeout in ms before a message is resent</description>
+                       </parameter>
+               </config-description>
+       </thing-type>
+
+</thing:thing-descriptions>
diff --git a/bundles/org.openhab.binding.echonetlite/src/test/java/org/openhab/binding/echonetlite/internal/EpcTest.java b/bundles/org.openhab.binding.echonetlite/src/test/java/org/openhab/binding/echonetlite/internal/EpcTest.java
new file mode 100644 (file)
index 0000000..c6cf26e
--- /dev/null
@@ -0,0 +1,43 @@
+/**
+ * Copyright (c) 2010-2022 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.echonetlite.internal;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.openhab.binding.echonetlite.internal.EchonetClass.AIRCON_HOMEAC;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+
+/**
+ * @author Michael Barker - Initial contribution
+ */
+@NonNullByDefault
+class EpcTest {
+
+    @Test
+    void shouldLookupEpc() {
+        final EchonetClass echonetClass = AIRCON_HOMEAC;
+
+        for (Epc epc : Epc.Device.values()) {
+            assertEquals(epc, Epc.lookup(echonetClass.groupCode(), echonetClass.classCode(), epc.code()));
+        }
+
+        for (Epc epc : Epc.AcGroup.values()) {
+            assertEquals(epc, Epc.lookup(echonetClass.groupCode(), echonetClass.classCode(), epc.code()));
+        }
+
+        for (Epc epc : Epc.HomeAc.values()) {
+            assertEquals(epc, Epc.lookup(echonetClass.groupCode(), echonetClass.classCode(), epc.code()));
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.echonetlite/src/test/java/org/openhab/binding/echonetlite/internal/protocol/LangUtilTest.java b/bundles/org.openhab.binding.echonetlite/src/test/java/org/openhab/binding/echonetlite/internal/protocol/LangUtilTest.java
new file mode 100644 (file)
index 0000000..a0b8f08
--- /dev/null
@@ -0,0 +1,31 @@
+/**
+ * Copyright (c) 2010-2022 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.echonetlite.internal.protocol;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.openhab.binding.echonetlite.internal.LangUtil.constantToVariable;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+
+/**
+ * @author Michael Barker - Initial contribution
+ */
+@NonNullByDefault
+class LangUtilTest {
+
+    @Test
+    void shouldConvertConstantToVariable() {
+        assertEquals("operationStatus", constantToVariable("OPERATION_STATUS"));
+    }
+}
diff --git a/bundles/org.openhab.binding.echonetlite/src/test/java/org/openhab/binding/echonetlite/internal/protocol/StateCodecTest.java b/bundles/org.openhab.binding.echonetlite/src/test/java/org/openhab/binding/echonetlite/internal/protocol/StateCodecTest.java
new file mode 100644 (file)
index 0000000..7d070f8
--- /dev/null
@@ -0,0 +1,130 @@
+/**
+ * Copyright (c) 2010-2022 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.echonetlite.internal.protocol;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.openhab.binding.echonetlite.internal.LangUtil.b;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.echonetlite.internal.StateCodec;
+import org.openhab.binding.echonetlite.internal.StateCodec.HexStringCodec;
+import org.openhab.binding.echonetlite.internal.StateCodec.OperatingTimeDecode;
+import org.openhab.binding.echonetlite.internal.StateCodec.Option;
+import org.openhab.binding.echonetlite.internal.StateCodec.OptionCodec;
+import org.openhab.binding.echonetlite.internal.StateCodec.StandardVersionInformationCodec;
+import org.openhab.binding.echonetlite.internal.StateDecode;
+import org.openhab.core.library.types.DecimalType;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.library.unit.SIUnits;
+import org.openhab.core.types.State;
+
+/**
+ * @author Michael Barker - Initial contribution
+ */
+@NonNullByDefault
+class StateCodecTest {
+    private void assertEncodeDecode(StateCodec stateCodec, State state, byte[] expectedOutput) {
+        final ByteBuffer buffer = ByteBuffer.allocate(1024);
+        stateCodec.encodeState(state, buffer);
+        buffer.flip();
+
+        final byte[] encoded = new byte[buffer.remaining()];
+        buffer.get(encoded);
+        assertArrayEquals(expectedOutput, encoded);
+
+        buffer.flip();
+
+        assertEquals(state, stateCodec.decodeState(buffer));
+    }
+
+    private void assertDecode(StateDecode stateDecode, State expectedState, byte[] data) {
+        assertEquals(expectedState, stateDecode.decodeState(ByteBuffer.wrap(data)));
+    }
+
+    @Test
+    void shouldEncodeOnOff() {
+        final int on = 34;
+        final int off = 27;
+        final StateCodec.OnOffCodec onOffCodec = new StateCodec.OnOffCodec(on, off);
+
+        assertEncodeDecode(onOffCodec, OnOffType.ON, new byte[] { b(on) });
+        assertEncodeDecode(onOffCodec, OnOffType.OFF, new byte[] { b(off) });
+    }
+
+    @Test
+    void shouldDecodeStandardVersionInformation() {
+        assertDecode(StandardVersionInformationCodec.INSTANCE, StringType.EMPTY, new byte[0]);
+        assertDecode(StandardVersionInformationCodec.INSTANCE, StringType.EMPTY, new byte[1]);
+        assertDecode(StandardVersionInformationCodec.INSTANCE, StringType.EMPTY, new byte[2]);
+        assertDecode(StandardVersionInformationCodec.INSTANCE, StringType.EMPTY, new byte[3]);
+        assertDecode(StandardVersionInformationCodec.INSTANCE, StringType.EMPTY, new byte[5]);
+        assertDecode(StandardVersionInformationCodec.INSTANCE, new StringType("A"), new byte[] { 0, 0, 'A', 0 });
+        assertDecode(StandardVersionInformationCodec.INSTANCE, new StringType("Z"), new byte[] { 0, 0, 'Z', 0 });
+    }
+
+    @Test
+    void shouldDecodeHexString() {
+        assertDecode(HexStringCodec.INSTANCE, new StringType("000102030467"), new byte[] { 0, 1, 2, 3, 4, b(0x67) });
+    }
+
+    @Test
+    void shouldDecodeCumulativeOperatingTime() {
+        final ByteBuffer buffer = ByteBuffer.wrap(new byte[5]);
+        buffer.order(ByteOrder.BIG_ENDIAN);
+
+        final int valueInSeconds = 484260;
+        final long valueInMinutes = TimeUnit.SECONDS.toMinutes(valueInSeconds);
+        buffer.put(b(0x42));
+        buffer.putInt((int) valueInMinutes);
+
+        buffer.flip();
+        buffer.order(ByteOrder.LITTLE_ENDIAN);
+        assertEquals(valueInSeconds, ((QuantityType<?>) OperatingTimeDecode.INSTANCE.decodeState(buffer)).intValue());
+
+        buffer.flip();
+        buffer.order(ByteOrder.BIG_ENDIAN);
+        assertEquals(valueInSeconds, ((QuantityType<?>) OperatingTimeDecode.INSTANCE.decodeState(buffer)).intValue());
+    }
+
+    @Test
+    void shouldEncodeDecodeOption() {
+        final OptionCodec optionCodec = new OptionCodec(new Option("ABC", 123), new Option("DEF", 101));
+        assertEncodeDecode(optionCodec, new StringType("ABC"), new byte[] { 123 });
+        assertEncodeDecode(optionCodec, new StringType("DEF"), new byte[] { 101 });
+    }
+
+    @Test
+    void shouldEncodeAndDecode8Bit() {
+        assertEncodeDecode(StateCodec.Decimal8bitCodec.INSTANCE, new DecimalType(123), new byte[] { 123 });
+        assertEncodeDecode(StateCodec.Decimal8bitCodec.INSTANCE, new DecimalType(1), new byte[] { 1 });
+        assertEncodeDecode(StateCodec.Decimal8bitCodec.INSTANCE, new DecimalType(-1), new byte[] { b(255) });
+    }
+
+    @Test
+    void shouldEncodeAndDecodeTemperature() {
+        assertEncodeDecode(StateCodec.Temperature8bitCodec.INSTANCE, new QuantityType<>(123, SIUnits.CELSIUS),
+                new byte[] { 123 });
+        assertEncodeDecode(StateCodec.Temperature8bitCodec.INSTANCE, new QuantityType<>(1, SIUnits.CELSIUS),
+                new byte[] { 1 });
+        assertEncodeDecode(StateCodec.Temperature8bitCodec.INSTANCE, new QuantityType<>(-1, SIUnits.CELSIUS),
+                new byte[] { b(255) });
+    }
+}
index e43e5ec6fefb1bfb7db9e8d6a66d5b1a2f91ec62..8ed05c5ad8cb9e76f7387e42497e8afc0900770b 100644 (file)
     <module>org.openhab.binding.dwdpollenflug</module>
     <module>org.openhab.binding.dwdunwetter</module>
     <module>org.openhab.binding.easee</module>
+    <module>org.openhab.binding.echonetlite</module>
     <module>org.openhab.binding.ecobee</module>
     <module>org.openhab.binding.ecotouch</module>
     <module>org.openhab.binding.ecowatt</module>