]> git.basschouten.com Git - openhab-addons.git/commitdiff
[iotawatt] Initial contribution (#16491)
authorPete <8108165+PRosenb@users.noreply.github.com>
Tue, 28 May 2024 19:37:59 +0000 (05:37 +1000)
committerGitHub <noreply@github.com>
Tue, 28 May 2024 19:37:59 +0000 (21:37 +0200)
* [iotawatt] generate new binding

Signed-off-by: Peter Rosenberg <prosenb.dev@gmail.com>
30 files changed:
CODEOWNERS
bom/openhab-addons/pom.xml
bundles/org.openhab.binding.iotawatt/NOTICE [new file with mode: 0644]
bundles/org.openhab.binding.iotawatt/README.md [new file with mode: 0644]
bundles/org.openhab.binding.iotawatt/pom.xml [new file with mode: 0644]
bundles/org.openhab.binding.iotawatt/src/main/feature/feature.xml [new file with mode: 0644]
bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/IoTaWattBindingConstants.java [new file with mode: 0644]
bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/IoTaWattConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/IoTaWattHandlerFactory.java [new file with mode: 0644]
bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/client/IoTaWattClient.java [new file with mode: 0644]
bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/client/IoTaWattClientCommunicationException.java [new file with mode: 0644]
bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/client/IoTaWattClientConfigurationException.java [new file with mode: 0644]
bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/client/IoTaWattClientException.java [new file with mode: 0644]
bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/client/IoTaWattClientInterruptedException.java [new file with mode: 0644]
bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/handler/FetchDataServiceProvider.java [new file with mode: 0644]
bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/handler/HttpClientProvider.java [new file with mode: 0644]
bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/handler/IoTaWattClientProvider.java [new file with mode: 0644]
bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/handler/IoTaWattHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/model/IoTaWattChannelType.java [new file with mode: 0644]
bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/model/StatusResponse.java [new file with mode: 0644]
bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/service/DeviceHandlerCallback.java [new file with mode: 0644]
bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/service/FetchDataService.java [new file with mode: 0644]
bundles/org.openhab.binding.iotawatt/src/main/resources/OH-INF/addon/addon.xml [new file with mode: 0644]
bundles/org.openhab.binding.iotawatt/src/main/resources/OH-INF/i18n/iotawatt.properties [new file with mode: 0644]
bundles/org.openhab.binding.iotawatt/src/main/resources/OH-INF/thing/thing-types.xml [new file with mode: 0644]
bundles/org.openhab.binding.iotawatt/src/test/java/org/openhab/binding/iotawatt/internal/client/IoTaWattClientTest.java [new file with mode: 0644]
bundles/org.openhab.binding.iotawatt/src/test/java/org/openhab/binding/iotawatt/internal/model/IoTaWattChannelTypeTest.java [new file with mode: 0644]
bundles/org.openhab.binding.iotawatt/src/test/java/org/openhab/binding/iotawatt/internal/service/FetchDataServiceTest.java [new file with mode: 0644]
bundles/org.openhab.binding.iotawatt/src/test/resources/apiResponses/device-status-response.json [new file with mode: 0644]
bundles/pom.xml

index 4bba270304108f9c9aba154882c468f4b330897f..fbe2c959274aeb6379a3fa104ddaaddd606dfb55 100644 (file)
 /bundles/org.openhab.binding.ihc/ @paulianttila
 /bundles/org.openhab.binding.insteon/ @robnielsen
 /bundles/org.openhab.binding.intesis/ @hmerk
+/bundles/org.openhab.binding.iotawatt/ @PRosenb
 /bundles/org.openhab.binding.ipcamera/ @Skinah
 /bundles/org.openhab.binding.ipobserver/ @Skinah
 /bundles/org.openhab.binding.ipp/ @peuter
index 839603965c4bb788d2820cca373ce1332d19c568..ae6156a70c0c41cc2bb45bf0b7b7f763704df558 100644 (file)
       <artifactId>org.openhab.binding.intesis</artifactId>
       <version>${project.version}</version>
     </dependency>
+    <dependency>
+      <groupId>org.openhab.addons.bundles</groupId>
+      <artifactId>org.openhab.binding.iotawatt</artifactId>
+      <version>${project.version}</version>
+    </dependency>
     <dependency>
       <groupId>org.openhab.addons.bundles</groupId>
       <artifactId>org.openhab.binding.ipcamera</artifactId>
diff --git a/bundles/org.openhab.binding.iotawatt/NOTICE b/bundles/org.openhab.binding.iotawatt/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.iotawatt/README.md b/bundles/org.openhab.binding.iotawatt/README.md
new file mode 100644 (file)
index 0000000..3f1dace
--- /dev/null
@@ -0,0 +1,86 @@
+# IoTaWatt Binding
+
+This binding integrates [IoTaWatt™ Open WiFi Electric Power Monitor](https://iotawatt.com/) into openHAB.
+
+Limitations of this version:
+
+- No authentication support
+
+## Supported Things
+
+The IoTaWatt binding supports one Thing called `iotawatt`.
+
+## Discovery
+
+The binding does not auto-discover the IoTaWatt device.  
+
+## Thing Configuration
+
+### IoTaWatt Thing Configuration
+
+| Name            | Type    | Description                                    | Default | Required | Advanced |
+|-----------------|---------|------------------------------------------------|---------|----------|----------|
+| hostname        | text    | Hostname or IP address of the device           | N/A     | yes      | no       |
+| refreshInterval | integer | Interval the device is polled in sec.          | 10      | no       | no       |
+| requestTimeout  | long    | The request timeout to call the device in sec. | 10      | no       | no       |
+
+## Channels
+
+The binding detects configured inputs and outputs and creates channels for them.
+
+| Channel             | Type                     | ID                  | Read/Write | Description                     |
+|---------------------|--------------------------|---------------------|------------|---------------------------------|
+| Amps                | Number:Power             | amps                | RO         | The current amps                |
+| Frequency           | Number:Frequency         | frequency           | RO         | The current AC frequency        |
+| Power Factor        | Number:Dimensionless     | power-factor        | RO         | The current power factor        |
+| Apparent Power      | Number:Power             | apparent-power      | RO         | The current apparent power      |
+| Reactive Power      | Number:Power             | reactive-power      | RO         | The current reactive power      |
+| Reactive Power hour | Number:Power             | reactive-power-hour | RO         | The current reactive power hour |
+| Voltage             | Number:ElectricPotential | voltage             | RO         | The current voltage             |
+| Power Consumption   | Number:Power             | watts               | RO         | The current power consumption   |
+| Phase               | Number:Dimensionless     | phase               | RO         | The current phase               |
+
+## Example Configuration
+
+### Thing with Channels
+
+```java
+Thing iotawatt:iotawatt:iotawatt1 "IoTaWatt 1" [ hostname="192.168.1.10" ] {
+ Channels:
+  Type voltage      : input_00#voltage "Voltage"
+  Type frequency    : input_00#frequency "AC Frequency"
+  Type phase        : input_00#phase "Phase"
+  Type watts        : input_01#watts "Power Consumption"
+  Type power-factor : input_01#power-factor "Power Factor"
+  Type phase        : input_01#phase "Phase"
+
+  Type amps : output_00#Input_1_amps "Amps"
+  Type frequency : output_01#Input_1_hz "Frequency"
+  Type power-factor : output_02#Input_1_pf "Power Factor"
+  Type apparent-power : output_03#Input_1_va "Apparent Power"
+  Type reactive-power : output_04#Input_1_var "Reactive Power"
+  Type reactive-power-hour : output_05#Input_1_varh "Reactive Power Hour"
+  Type voltage : output_06#Input_1_volts "Voltage"
+  Type watts : output_07#Input_1_watts "Watts"
+}
+```
+
+### Items
+
+```java
+Number:ElectricPotential input_voltage "Voltage"           { channel="iotawatt:iotawatt:iotawatt1:input_00#voltage"  }
+Number:Frequency input_frequency "AC Frequency"    { channel="iotawatt:iotawatt:iotawatt1:input_00#frequency"  }
+Number:Dimensionless input_phase0 "Phase"               { channel="iotawatt:iotawatt:iotawatt1:input_00#phase" }
+Number:Power input_watts "Watts"               { channel="iotawatt:iotawatt:iotawatt1:input_01#watts" }
+Number:Dimensionless input_power_factor "Power Factor" { channel="iotawatt:iotawatt:iotawatt1:input_01#power-factor" }
+Number:Dimensionless input_phase1 "Phase"               { channel="iotawatt:iotawatt:iotawatt1:input_01#phase" }
+
+Number:ElectricCurrent output_amps "Amps"                               { channel="iotawatt:iotawatt:iotawatt1:output_00#Input_1_amps" }
+Number:Frequency output_frequency "AC Frequency"                     { channel="iotawatt:iotawatt:iotawatt1:output_01#Input_1_hz" }
+Number:Dimensionless output_power_factor "Power Factor"               { channel="iotawatt:iotawatt:iotawatt1:output_02#Input_1_pf" }
+Number:Power output_apparent_power "Apparent Power"           { channel="iotawatt:iotawatt:iotawatt1:output_03#Input_1_va" }
+Number:Power output_reactive_power "Reactive Power"           { channel="iotawatt:iotawatt:iotawatt1:output_04#Input_1_var" }
+Number:Energy output_reactive_power_hour "Reactive Power Hour" { channel="iotawatt:iotawatt:iotawatt1:output_05#Input_1_varh" }
+Number:ElectricPotential output_voltage "Voltage"                         { channel="iotawatt:iotawatt:iotawatt1:output_06#Input_1_volts" }
+Number:Power output_watts "Watts"                             { channel="iotawatt:iotawatt:iotawatt1:output_07#Input_1_watts" }
+```
diff --git a/bundles/org.openhab.binding.iotawatt/pom.xml b/bundles/org.openhab.binding.iotawatt/pom.xml
new file mode 100644 (file)
index 0000000..609e155
--- /dev/null
@@ -0,0 +1,25 @@
+<?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>4.2.0-SNAPSHOT</version>
+  </parent>
+
+  <artifactId>org.openhab.binding.iotawatt</artifactId>
+
+  <name>openHAB Add-ons :: Bundles :: IoTaWatt Binding</name>
+
+  <dependencies>
+    <dependency>
+      <groupId>org.mockito</groupId>
+      <artifactId>mockito-core</artifactId>
+      <version>5.11.0</version>
+      <scope>test</scope>
+    </dependency>
+  </dependencies>
+</project>
diff --git a/bundles/org.openhab.binding.iotawatt/src/main/feature/feature.xml b/bundles/org.openhab.binding.iotawatt/src/main/feature/feature.xml
new file mode 100644 (file)
index 0000000..97902fe
--- /dev/null
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<features name="org.openhab.binding.iotawatt-${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-iotawatt" description="IoTaWatt Binding" version="${project.version}">
+               <feature>openhab-runtime-base</feature>
+               <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.iotawatt/${project.version}</bundle>
+       </feature>
+</features>
diff --git a/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/IoTaWattBindingConstants.java b/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/IoTaWattBindingConstants.java
new file mode 100644 (file)
index 0000000..dced823
--- /dev/null
@@ -0,0 +1,35 @@
+/**
+ * Copyright (c) 2010-2024 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.iotawatt.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.thing.ThingTypeUID;
+
+/**
+ * The {@link IoTaWattBindingConstants} class defines common constants, which are
+ * used across the whole binding.
+ *
+ * @author Peter Rosenberg - Initial contribution
+ */
+@NonNullByDefault
+public class IoTaWattBindingConstants {
+    /**
+     * The binding ID of the IoTaWatt binding
+     */
+    public static final String BINDING_ID = "iotawatt";
+
+    /**
+     * The list of all Thing Type UIDs
+     */
+    public static final ThingTypeUID THING_TYPE_IOTAWATT = new ThingTypeUID(BINDING_ID, "iotawatt");
+}
diff --git a/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/IoTaWattConfiguration.java b/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/IoTaWattConfiguration.java
new file mode 100644 (file)
index 0000000..98647fe
--- /dev/null
@@ -0,0 +1,65 @@
+/**
+ * Copyright (c) 2010-2024 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.iotawatt.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link IoTaWattConfiguration} class contains fields mapping thing configuration parameters.
+ *
+ * @author Peter Rosenberg - Initial contribution
+ */
+@NonNullByDefault
+public class IoTaWattConfiguration {
+    private final Logger logger = LoggerFactory.getLogger(IoTaWattConfiguration.class);
+    /**
+     * The default refresh interval of the IoTaWatt device
+     */
+    public static final int REFRESH_INTERVAL_DEFAULT = 10;
+    /**
+     * The default of the request timeout
+     */
+    public static final long REQUEST_TIMEOUT_DEFAULT = 10;
+
+    /**
+     * Configuration parameters
+     */
+    public String hostname = "";
+    /**
+     * The request timeout in seconds when fetching data from the IoTaWatt device
+     */
+    public long requestTimeout = REQUEST_TIMEOUT_DEFAULT;
+    /**
+     * The refresh interval of the IoTaWatt device in seconds
+     */
+    public int refreshInterval = REFRESH_INTERVAL_DEFAULT;
+
+    public boolean isValid() {
+        if (hostname.trim().isBlank()) {
+            logger.warn("Hostname is blank, please specify the hostname/IP address of IoTaWatt.");
+            return false;
+        }
+        if (requestTimeout <= 0) {
+            logger.warn("Invalid requestTimeout {}, please use a positive number", requestTimeout);
+            return false;
+        }
+        if (refreshInterval <= 0) {
+            logger.warn("Invalid refreshInterval {}, please use a positive number", refreshInterval);
+            return false;
+        }
+        // Also update "configuration-error" in src/main/resources/OH-INF/i18n/iotawatt_en.properties
+        return true;
+    }
+}
diff --git a/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/IoTaWattHandlerFactory.java b/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/IoTaWattHandlerFactory.java
new file mode 100644 (file)
index 0000000..82f74ae
--- /dev/null
@@ -0,0 +1,97 @@
+/**
+ * Copyright (c) 2010-2024 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.iotawatt.internal;
+
+import static org.openhab.binding.iotawatt.internal.IoTaWattBindingConstants.THING_TYPE_IOTAWATT;
+
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.util.ssl.SslContextFactory;
+import org.openhab.binding.iotawatt.internal.client.IoTaWattClient;
+import org.openhab.binding.iotawatt.internal.handler.FetchDataServiceProvider;
+import org.openhab.binding.iotawatt.internal.handler.HttpClientProvider;
+import org.openhab.binding.iotawatt.internal.handler.IoTaWattClientProvider;
+import org.openhab.binding.iotawatt.internal.handler.IoTaWattHandler;
+import org.openhab.binding.iotawatt.internal.service.DeviceHandlerCallback;
+import org.openhab.binding.iotawatt.internal.service.FetchDataService;
+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;
+import org.osgi.service.component.annotations.Deactivate;
+
+import com.google.gson.Gson;
+
+/**
+ * The {@link IoTaWattHandlerFactory} is responsible for creating things and thing
+ * handlers.
+ *
+ * @author Peter Rosenberg - Initial contribution
+ */
+@NonNullByDefault
+@Component(configurationPid = "binding.iotawatt", service = ThingHandlerFactory.class)
+public class IoTaWattHandlerFactory extends BaseThingHandlerFactory
+        implements HttpClientProvider, IoTaWattClientProvider, FetchDataServiceProvider {
+    private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_IOTAWATT);
+
+    private final HttpClient insecureClient;
+    private final Gson gson = new Gson();
+
+    /**
+     * Creates a IoTaWattHandlerFactory
+     */
+    public IoTaWattHandlerFactory() {
+        this.insecureClient = new HttpClient(new SslContextFactory.Client(true));
+    }
+
+    @Override
+    public boolean supportsThingType(ThingTypeUID thingTypeUID) {
+        return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
+    }
+
+    @Override
+    protected @Nullable ThingHandler createHandler(Thing thing) {
+        ThingTypeUID thingTypeUID = thing.getThingTypeUID();
+
+        if (THING_TYPE_IOTAWATT.equals(thingTypeUID)) {
+            return new IoTaWattHandler(thing, this, this);
+        }
+
+        return null;
+    }
+
+    @Override
+    public HttpClient getInsecureClient() {
+        return insecureClient;
+    }
+
+    @Override
+    public IoTaWattClient getIoTaWattClient(String hostname, long requestTimeout) {
+        return new IoTaWattClient(hostname, requestTimeout, insecureClient, gson);
+    }
+
+    @Override
+    public FetchDataService getFetchDataService(DeviceHandlerCallback deviceHandlerCallback) {
+        return new FetchDataService(deviceHandlerCallback);
+    }
+
+    @Deactivate
+    public void deactivate() {
+        insecureClient.destroy();
+    }
+}
diff --git a/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/client/IoTaWattClient.java b/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/client/IoTaWattClient.java
new file mode 100644 (file)
index 0000000..a6df365
--- /dev/null
@@ -0,0 +1,130 @@
+/**
+ * Copyright (c) 2010-2024 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.iotawatt.internal.client;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.Optional;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.client.api.ContentResponse;
+import org.eclipse.jetty.client.api.Request;
+import org.eclipse.jetty.http.HttpMethod;
+import org.eclipse.jetty.http.HttpStatus;
+import org.openhab.binding.iotawatt.internal.model.StatusResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.Gson;
+
+/**
+ * Encapsulates the communication with the IoTaWatt device.
+ *
+ * @author Peter Rosenberg - Initial contribution
+ */
+@NonNullByDefault
+public class IoTaWattClient {
+    private static final String REQUEST_URL = "http://%s/status?state=&inputs=&outputs=";
+
+    private final Logger logger = LoggerFactory.getLogger(IoTaWattClient.class);
+
+    /**
+     * The hostname the IoTaWattClient connects to
+     */
+    public final String hostname;
+    private final long requestTimeout;
+    private final HttpClient httpClient;
+    private final Gson gson;
+
+    /**
+     * Creates an IoTaWattClient
+     * 
+     * @param hostname The hostname of the IoTaWatt device to connect to
+     * @param httpClient The HttpClient to use
+     * @param gson The Gson decoder to use
+     */
+    public IoTaWattClient(String hostname, long requestTimeout, HttpClient httpClient, Gson gson) {
+        this.httpClient = httpClient;
+        this.requestTimeout = requestTimeout;
+        this.hostname = hostname;
+        this.gson = gson;
+    }
+
+    public void start() {
+        try {
+            httpClient.start();
+        } catch (Exception e) {
+            // catching exception is necessary due to the signature of HttpClient.start()
+            logger.warn("Failed to start http client: {}", e.getMessage());
+            throw new IllegalStateException("Could not create HttpClient", e);
+        }
+    }
+
+    public void stop() {
+        try {
+            httpClient.stop();
+        } catch (Exception e) {
+            // catching exception is necessary due to the signature of HttpClient.stop()
+            logger.warn("Failed to stop http client: {}", e.getMessage());
+        }
+    }
+
+    /**
+     * Fetch the current status from the device.
+     * The errors are handled by the caller to update the Thing status accordingly.
+     *
+     * @throws IoTaWattClientCommunicationException On communication errors
+     * @throws IoTaWattClientInterruptedException When sending the request is interrupted
+     * @throws IoTaWattClientConfigurationException When the URI is wrong
+     * @throws IoTaWattClientException When an unknown error occurs
+     * @return The optional StatusResponse fetched from the device
+     */
+    public Optional<StatusResponse> fetchStatus() throws IoTaWattClientCommunicationException,
+            IoTaWattClientInterruptedException, IoTaWattClientException, IoTaWattClientConfigurationException {
+        try {
+            final URI uri = new URI(String.format(REQUEST_URL, hostname));
+            final Request request = httpClient.newRequest(uri).method(HttpMethod.GET).timeout(requestTimeout,
+                    TimeUnit.SECONDS);
+            final ContentResponse response = request.send();
+            if (response.getStatus() != HttpStatus.OK_200) {
+                throw new IoTaWattClientCommunicationException("HttpStatus " + response.getStatus());
+            }
+            final String content = response.getContentAsString();
+            @Nullable
+            final StatusResponse statusResponse = gson.fromJson(content, StatusResponse.class);
+            logger.trace("statusResponse: {}", statusResponse);
+            if (statusResponse.inputs() == null) {
+                logger.warn("List of inputs in response from IoTaWatt is null on device {}.", hostname);
+            }
+            if (statusResponse.outputs() == null) {
+                logger.warn("List of outputs in response from IoTaWatt is null on device {}.", hostname);
+            }
+            // noinspection ConstantConditions
+            return Optional.ofNullable(statusResponse);
+        } catch (InterruptedException e) {
+            throw new IoTaWattClientInterruptedException();
+        } catch (TimeoutException e) {
+            throw new IoTaWattClientCommunicationException();
+        } catch (URISyntaxException e) {
+            throw new IoTaWattClientConfigurationException(e);
+        } catch (ExecutionException e) {
+            logger.debug("Error on getting data from IoTaWatt {}", hostname);
+            throw new IoTaWattClientException();
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/client/IoTaWattClientCommunicationException.java b/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/client/IoTaWattClientCommunicationException.java
new file mode 100644 (file)
index 0000000..e882df2
--- /dev/null
@@ -0,0 +1,32 @@
+/**
+ * Copyright (c) 2010-2024 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.iotawatt.internal.client;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Thrown on communication errors with the IoTaWatt device.
+ *
+ * @author Peter Rosenberg - Initial contribution
+ */
+@NonNullByDefault
+public class IoTaWattClientCommunicationException extends Exception {
+    static final long serialVersionUID = 7960876940928850536L;
+
+    IoTaWattClientCommunicationException() {
+    }
+
+    public IoTaWattClientCommunicationException(String message) {
+        super(message);
+    }
+}
diff --git a/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/client/IoTaWattClientConfigurationException.java b/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/client/IoTaWattClientConfigurationException.java
new file mode 100644 (file)
index 0000000..2bd5ffa
--- /dev/null
@@ -0,0 +1,32 @@
+/**
+ * Copyright (c) 2010-2024 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.iotawatt.internal.client;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Thrown on configuration errors.
+ *
+ * @author Peter Rosenberg - Initial contribution
+ */
+@NonNullByDefault
+public class IoTaWattClientConfigurationException extends Exception {
+    static final long serialVersionUID = 4028095925746584345L;
+
+    public IoTaWattClientConfigurationException() {
+    }
+
+    public IoTaWattClientConfigurationException(Throwable cause) {
+        super(cause);
+    }
+}
diff --git a/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/client/IoTaWattClientException.java b/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/client/IoTaWattClientException.java
new file mode 100644 (file)
index 0000000..1832907
--- /dev/null
@@ -0,0 +1,32 @@
+/**
+ * Copyright (c) 2010-2024 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.iotawatt.internal.client;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Thrown on unknown IoTaWattClient errors.
+ *
+ * @author Peter Rosenberg - Initial contribution
+ */
+@NonNullByDefault
+public class IoTaWattClientException extends Throwable {
+    static final long serialVersionUID = 411877996315818807L;
+
+    public IoTaWattClientException() {
+    }
+
+    public IoTaWattClientException(Throwable cause) {
+        super(cause);
+    }
+}
diff --git a/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/client/IoTaWattClientInterruptedException.java b/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/client/IoTaWattClientInterruptedException.java
new file mode 100644 (file)
index 0000000..bbf4de1
--- /dev/null
@@ -0,0 +1,25 @@
+/**
+ * Copyright (c) 2010-2024 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.iotawatt.internal.client;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Thrown when the thread is interrupted.
+ *
+ * @author Peter Rosenberg - Initial contribution
+ */
+@NonNullByDefault
+public class IoTaWattClientInterruptedException extends Exception {
+    static final long serialVersionUID = -3355456899013127876L;
+}
diff --git a/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/handler/FetchDataServiceProvider.java b/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/handler/FetchDataServiceProvider.java
new file mode 100644 (file)
index 0000000..dbe934e
--- /dev/null
@@ -0,0 +1,33 @@
+/**
+ * Copyright (c) 2010-2024 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.iotawatt.internal.handler;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.iotawatt.internal.service.DeviceHandlerCallback;
+import org.openhab.binding.iotawatt.internal.service.FetchDataService;
+
+/**
+ * Provides a FetchDataService.
+ *
+ * @author Peter Rosenberg - Initial contribution
+ */
+@NonNullByDefault
+public interface FetchDataServiceProvider {
+    /**
+     * Get the service to handle data fetching.
+     *
+     * @param deviceHandlerCallback The DeviceHandlerCallback to assign to the FetchDataService
+     * @return The provided FetchDataService
+     */
+    FetchDataService getFetchDataService(DeviceHandlerCallback deviceHandlerCallback);
+}
diff --git a/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/handler/HttpClientProvider.java b/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/handler/HttpClientProvider.java
new file mode 100644 (file)
index 0000000..766692b
--- /dev/null
@@ -0,0 +1,31 @@
+/**
+ * Copyright (c) 2010-2024 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.iotawatt.internal.handler;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jetty.client.HttpClient;
+
+/**
+ * Provides a HttpClient.
+ *
+ * @author Peter Rosenberg - Initial contribution
+ */
+@NonNullByDefault
+public interface HttpClientProvider {
+    /**
+     * Get the insecure http client (ignores SSL errors)
+     * 
+     * @return The provided HttpClient
+     */
+    HttpClient getInsecureClient();
+}
diff --git a/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/handler/IoTaWattClientProvider.java b/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/handler/IoTaWattClientProvider.java
new file mode 100644 (file)
index 0000000..1c5a964
--- /dev/null
@@ -0,0 +1,32 @@
+/**
+ * Copyright (c) 2010-2024 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.iotawatt.internal.handler;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.iotawatt.internal.client.IoTaWattClient;
+
+/**
+ * Provides an IoTaWattClient.
+ *
+ * @author Peter Rosenberg - Initial contribution
+ */
+@NonNullByDefault
+public interface IoTaWattClientProvider {
+    /**
+     * get the client to talk to IoTaWatt
+     * 
+     * @param hostname The hostname of the IoTaWatt device
+     * @return The provided IoTaWattClient
+     */
+    IoTaWattClient getIoTaWattClient(String hostname, long requestTimeout);
+}
diff --git a/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/handler/IoTaWattHandler.java b/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/handler/IoTaWattHandler.java
new file mode 100644 (file)
index 0000000..77398b6
--- /dev/null
@@ -0,0 +1,144 @@
+/**
+ * Copyright (c) 2010-2024 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.iotawatt.internal.handler;
+
+import static org.openhab.binding.iotawatt.internal.IoTaWattBindingConstants.BINDING_ID;
+
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.iotawatt.internal.IoTaWattConfiguration;
+import org.openhab.binding.iotawatt.internal.client.IoTaWattClient;
+import org.openhab.binding.iotawatt.internal.model.IoTaWattChannelType;
+import org.openhab.binding.iotawatt.internal.service.DeviceHandlerCallback;
+import org.openhab.binding.iotawatt.internal.service.FetchDataService;
+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.ThingUID;
+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.State;
+
+/**
+ * The {@link IoTaWattHandler} is responsible for the communication between the external device and openHAB.
+ *
+ * @author Peter Rosenberg - Initial contribution
+ */
+@NonNullByDefault
+public class IoTaWattHandler extends BaseThingHandler implements DeviceHandlerCallback {
+    private final IoTaWattClientProvider ioTaWattClientProvider;
+    private final FetchDataService fetchDataService;
+    private @Nullable IoTaWattClient ioTaWattClient;
+    private @Nullable ScheduledFuture<?> fetchDataJob;
+
+    /**
+     * Creates an IoTaWattHandler
+     * 
+     * @param thing The Thing of the IoTaWattHandler
+     * @param ioTaWattClientProvider The IoTaWattClientProvider to use
+     * @param fetchDataServiceProvider The FetchDataServiceProvider to use to fetch data
+     */
+    public IoTaWattHandler(Thing thing, IoTaWattClientProvider ioTaWattClientProvider,
+            FetchDataServiceProvider fetchDataServiceProvider) {
+        super(thing);
+        this.ioTaWattClientProvider = ioTaWattClientProvider;
+        this.fetchDataService = fetchDataServiceProvider.getFetchDataService(this);
+    }
+
+    @Override
+    public void handleCommand(ChannelUID channelUID, Command command) {
+    }
+
+    @Override
+    public void initialize() {
+        final IoTaWattConfiguration config = getConfigAs(IoTaWattConfiguration.class);
+        if (!config.isValid()) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/configuration-error");
+            return;
+        }
+
+        final IoTaWattClient ioTaWattClient = ioTaWattClientProvider.getIoTaWattClient(config.hostname,
+                config.requestTimeout);
+        ioTaWattClient.start();
+        fetchDataService.setIoTaWattClient(ioTaWattClient);
+        this.ioTaWattClient = ioTaWattClient;
+
+        updateStatus(ThingStatus.UNKNOWN);
+
+        fetchDataJob = scheduler.scheduleWithFixedDelay(fetchDataService::pollDevice, 0, config.refreshInterval,
+                TimeUnit.SECONDS);
+    }
+
+    @Override
+    public void dispose() {
+        ScheduledFuture<?> fetchDataJobLocal = this.fetchDataJob;
+        if (fetchDataJobLocal != null) {
+            fetchDataJobLocal.cancel(true);
+            this.fetchDataJob = null;
+        }
+        IoTaWattClient ioTaWattClient = this.ioTaWattClient;
+        if (ioTaWattClient != null) {
+            ioTaWattClient.stop();
+            this.ioTaWattClient = null;
+        }
+        super.dispose();
+    }
+
+    // --------------------------------------------------------------------------------------------
+    // Callbacks
+    // --------------------------------------------------------------------------------------------
+    @Override
+    public void updateStatus(ThingStatus status) {
+        super.updateStatus(status);
+    }
+
+    @Override
+    public void updateStatus(ThingStatus status, ThingStatusDetail statusDetail) {
+        super.updateStatus(status, statusDetail);
+    }
+
+    @Override
+    public void updateStatus(ThingStatus status, ThingStatusDetail statusDetail, @Nullable String description) {
+        super.updateStatus(status, statusDetail, description);
+    }
+
+    @Override
+    public void updateState(ChannelUID channelUID, State state) {
+        super.updateState(channelUID, state);
+    }
+
+    @Override
+    public ThingUID getThingUID() {
+        return getThing().getUID();
+    }
+
+    @Override
+    public void addChannelIfNotExists(ChannelUID channelUID, IoTaWattChannelType ioTaWattChannelType) {
+        final ChannelTypeUID channelTypeUID = new ChannelTypeUID(BINDING_ID, ioTaWattChannelType.typeId);
+        if (getThing().getChannel(channelUID) == null) {
+            final ThingBuilder thingBuilder = editThing();
+            final Channel channel = ChannelBuilder.create(channelUID, ioTaWattChannelType.acceptedItemType)
+                    .withType(channelTypeUID).build();
+            thingBuilder.withChannel(channel);
+            updateThing(thingBuilder.build());
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/model/IoTaWattChannelType.java b/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/model/IoTaWattChannelType.java
new file mode 100644 (file)
index 0000000..15f0027
--- /dev/null
@@ -0,0 +1,115 @@
+/**
+ * Copyright (c) 2010-2024 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.iotawatt.internal.model;
+
+import javax.measure.Unit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.library.unit.Units;
+
+/**
+ * Enum for each channel type of IoTaWatt supported by this binding.
+ *
+ * @author Peter Rosenberg - Initial contribution
+ */
+@NonNullByDefault
+public enum IoTaWattChannelType {
+    /**
+     * Electrical current
+     */
+    AMPS("amps", "amps", "Number:power", Units.AMPERE),
+    /**
+     * AC Frequency
+     */
+    FREQUENCY("frequency", "frequency", "Number:Frequency", Units.HERTZ),
+    /**
+     * Power Factor
+     */
+    POWER_FACTOR("power-factor", "power-factor", "Number:Dimensionless", Units.ONE),
+    /**
+     * Apparent Power
+     */
+    APPARENT_POWER("apparent-power", "apparent-power", "Number:power", Units.VOLT_AMPERE),
+    /**
+     * Reactive Power
+     */
+    REACTIVE_POWER("reactive-power", "reactive-power", "Number:power", Units.VAR),
+    /**
+     * Reactive Power Hour
+     */
+    REACTIVE_POWER_HOUR("reactive-power-hour", "reactive-power-hour", "Number:Energy", Units.VAR_HOUR),
+    /**
+     * Voltage
+     */
+    VOLTAGE("voltage", "voltage", "Number:ElectricPotential", Units.VOLT),
+    /**
+     * Watt, Active Power
+     */
+    WATTS("watts", "watts", "Number:Power", Units.WATT),
+    /**
+     * Phase
+     */
+    PHASE("phase", "phase", "Number:Dimensionless", Units.ONE);
+
+    /**
+     * Id of the channel in XML definition channel-type id.
+     */
+    public final String typeId;
+    /**
+     * Defines the last part of the channel UID.
+     */
+    public final String channelIdSuffix;
+    /**
+     * The value type the channel accepts.
+     */
+    public final String acceptedItemType;
+    /**
+     * The unit of the channel.
+     */
+    public final Unit<?> unit;
+
+    /**
+     * Creates an IoTaWattChannelType
+     * 
+     * @param typeId The TypeId
+     * @param channelIdSuffix The suffix of the channelId
+     * @param acceptedItemType The acceptedItemType
+     * @param unit The unit of the channel
+     */
+    IoTaWattChannelType(String typeId, String channelIdSuffix, String acceptedItemType, Unit<?> unit) {
+        this.acceptedItemType = acceptedItemType;
+        this.typeId = typeId;
+        this.channelIdSuffix = channelIdSuffix;
+        this.unit = unit;
+    }
+
+    /**
+     * Gets an IoTaWattChannelType
+     * 
+     * @param value The units to get an IoTaWattChannelType from
+     * @return The IoTaWattChannelType
+     */
+    public static IoTaWattChannelType fromOutputUnits(String value) {
+        return switch (value) {
+            case "Amps" -> IoTaWattChannelType.AMPS;
+            case "Hz" -> IoTaWattChannelType.FREQUENCY;
+            case "PF" -> IoTaWattChannelType.POWER_FACTOR;
+            case "VA" -> IoTaWattChannelType.APPARENT_POWER;
+            case "VAR" -> IoTaWattChannelType.REACTIVE_POWER;
+            case "VARh" -> IoTaWattChannelType.REACTIVE_POWER_HOUR;
+            case "Volts" -> IoTaWattChannelType.VOLTAGE;
+            case "Watts" -> IoTaWattChannelType.WATTS;
+            default -> throw new IllegalArgumentException("Unknown value " + value);
+        };
+    }
+}
diff --git a/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/model/StatusResponse.java b/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/model/StatusResponse.java
new file mode 100644 (file)
index 0000000..ffe0851
--- /dev/null
@@ -0,0 +1,53 @@
+/**
+ * Copyright (c) 2010-2024 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.iotawatt.internal.model;
+
+import java.util.List;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * Status response of IoTaWatt.
+ *
+ * @author Peter Rosenberg - Initial contribution
+ */
+@NonNullByDefault
+public record StatusResponse(@Nullable List<Input> inputs, @Nullable List<Output> outputs) {
+    /**
+     * Represents the inputs of IoTaWatt
+     * 
+     * @param channel The channel ID
+     * @param vrms Current VRMS
+     * @param hz Current frequency
+     * @param phase Current phase
+     * @param watts Current watts
+     * @param pf Current power factor
+     */
+    public record Input(int channel, @Nullable @SerializedName("Vrms") Float vrms,
+            @Nullable @SerializedName("Hz") Float hz, @Nullable Float phase,
+            @Nullable @SerializedName("Watts") Float watts, @Nullable @SerializedName("Pf") Float pf) {
+    }
+
+    /**
+     * Represents the outputs of IoTaWatt
+     * 
+     * @param name Name of the output
+     * @param units Unit of the output
+     * @param value Current value of the output
+     */
+    public record Output(String name, String units, Float value) {
+    }
+}
diff --git a/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/service/DeviceHandlerCallback.java b/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/service/DeviceHandlerCallback.java
new file mode 100644 (file)
index 0000000..9c119d1
--- /dev/null
@@ -0,0 +1,76 @@
+/**
+ * Copyright (c) 2010-2024 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.iotawatt.internal.service;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.iotawatt.internal.model.IoTaWattChannelType;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.ThingUID;
+import org.openhab.core.types.State;
+
+/**
+ * Allows the service to do callback to the device handler.
+ *
+ * @author Peter Rosenberg - Initial contribution
+ */
+@NonNullByDefault
+public interface DeviceHandlerCallback {
+    /**
+     * Updates the status of the thing. The detail of the status will be 'NONE'.
+     *
+     * @param status the status
+     */
+    void updateStatus(ThingStatus status);
+
+    /**
+     * Updates the status of the thing.
+     *
+     * @param status the status
+     * @param statusDetail the detail of the status
+     */
+    void updateStatus(ThingStatus status, ThingStatusDetail statusDetail);
+
+    /**
+     * Updates the status of the thing.
+     *
+     * @param status the status
+     * @param statusDetail the detail of the status
+     * @param description the description of the status
+     */
+    void updateStatus(ThingStatus status, ThingStatusDetail statusDetail, @Nullable String description);
+
+    /**
+     *
+     * Updates the state of the thing.
+     *
+     * @param channelUID unique id of the channel, which was updated
+     * @param state new state
+     */
+    void updateState(ChannelUID channelUID, State state);
+
+    /**
+     * @return The ThingUID of the Thing
+     */
+    ThingUID getThingUID();
+
+    /**
+     * Adds the channel to the Thing if the channel does not yet exist.
+     * 
+     * @param channelUID The ChannelUID of the channel to add
+     * @param ioTaWattChannelType The IoTaWattChannelType of the channel to add
+     */
+    void addChannelIfNotExists(ChannelUID channelUID, IoTaWattChannelType ioTaWattChannelType);
+}
diff --git a/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/service/FetchDataService.java b/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/service/FetchDataService.java
new file mode 100644 (file)
index 0000000..e5123f7
--- /dev/null
@@ -0,0 +1,147 @@
+/**
+ * Copyright (c) 2010-2024 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.iotawatt.internal.service;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.iotawatt.internal.client.IoTaWattClient;
+import org.openhab.binding.iotawatt.internal.client.IoTaWattClientCommunicationException;
+import org.openhab.binding.iotawatt.internal.client.IoTaWattClientConfigurationException;
+import org.openhab.binding.iotawatt.internal.client.IoTaWattClientException;
+import org.openhab.binding.iotawatt.internal.client.IoTaWattClientInterruptedException;
+import org.openhab.binding.iotawatt.internal.model.IoTaWattChannelType;
+import org.openhab.binding.iotawatt.internal.model.StatusResponse;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+
+/**
+ * Fetches data from IoTaWatt and updates the channels accordingly.
+ *
+ * @author Peter Rosenberg - Initial contribution
+ */
+@NonNullByDefault
+public class FetchDataService {
+    static final String INPUT_CHANNEL_ID_PREFIX = "input_";
+    static final String OUTPUT_CHANNEL_ID_PREFIX = "output_";
+
+    private final DeviceHandlerCallback deviceHandlerCallback;
+    private @Nullable IoTaWattClient ioTaWattClient;
+
+    /**
+     * Creates a FetchDataService.
+     * 
+     * @param deviceHandlerCallback The ThingHandler used for callbacks
+     */
+    public FetchDataService(DeviceHandlerCallback deviceHandlerCallback) {
+        this.deviceHandlerCallback = deviceHandlerCallback;
+    }
+
+    /**
+     * Setter for the IoTaWattClient
+     * 
+     * @param ioTaWattClient The IoTaWattClient to use
+     */
+    public void setIoTaWattClient(IoTaWattClient ioTaWattClient) {
+        this.ioTaWattClient = ioTaWattClient;
+    }
+
+    /**
+     * Poll the device once without retry.
+     * Handles error cases and updates the Thing accordingly.
+     */
+    public void pollDevice() {
+        Optional.ofNullable(ioTaWattClient).ifPresentOrElse(this::pollDevice, () -> deviceHandlerCallback
+                .updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.HANDLER_INITIALIZING_ERROR));
+    }
+
+    private void pollDevice(IoTaWattClient client) {
+        try {
+            final Optional<StatusResponse> statusResponse = client.fetchStatus();
+            if (statusResponse.isPresent()) {
+                deviceHandlerCallback.updateStatus(ThingStatus.ONLINE);
+                updateChannels(statusResponse.get());
+            } else {
+                deviceHandlerCallback.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
+            }
+        } catch (IoTaWattClientInterruptedException e) {
+            deviceHandlerCallback.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NOT_YET_READY);
+        } catch (IoTaWattClientCommunicationException e) {
+            deviceHandlerCallback.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
+        } catch (IoTaWattClientConfigurationException e) {
+            deviceHandlerCallback.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+                    getErrorMessage(e));
+        } catch (IoTaWattClientException e) {
+            deviceHandlerCallback.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, getErrorMessage(e));
+        }
+    }
+
+    @Nullable
+    private String getErrorMessage(Throwable t) {
+        final Throwable cause = t.getCause();
+        return Objects.requireNonNullElse(cause, t).getMessage();
+    }
+
+    private void updateChannels(StatusResponse statusResponse) {
+        Optional.ofNullable(statusResponse.inputs()).ifPresent(this::updateInputs);
+        Optional.ofNullable(statusResponse.outputs()).ifPresent(this::updateOutputs);
+    }
+
+    private void updateInputs(List<StatusResponse.Input> inputs) {
+        for (final StatusResponse.Input input : inputs) {
+            final int channelNumber = input.channel();
+            createAndUpdateInputChannel(channelNumber, input.watts(), IoTaWattChannelType.WATTS);
+            createAndUpdateInputChannel(channelNumber, input.vrms(), IoTaWattChannelType.VOLTAGE);
+            createAndUpdateInputChannel(channelNumber, input.hz(), IoTaWattChannelType.FREQUENCY);
+            createAndUpdateInputChannel(channelNumber, input.pf(), IoTaWattChannelType.POWER_FACTOR);
+            createAndUpdateInputChannel(channelNumber, input.phase(), IoTaWattChannelType.PHASE);
+        }
+    }
+
+    private void updateOutputs(List<StatusResponse.Output> outputs) {
+        int index = 0;
+        for (final StatusResponse.Output output : outputs) {
+            final ChannelUID channelUID = new ChannelUID(deviceHandlerCallback.getThingUID(),
+                    OUTPUT_CHANNEL_ID_PREFIX + toTwoDigits(index++) + "#" + output.name());
+            final Float value = output.value();
+            final IoTaWattChannelType ioTaWattChannelType = IoTaWattChannelType.fromOutputUnits(output.units());
+            deviceHandlerCallback.addChannelIfNotExists(channelUID, ioTaWattChannelType);
+            deviceHandlerCallback.updateState(channelUID, new QuantityType<>(value, ioTaWattChannelType.unit));
+            // TODO removed channels are not in array anymore
+        }
+    }
+
+    private void createAndUpdateInputChannel(int channelNumber, @Nullable Number value,
+            IoTaWattChannelType ioTaWattChannelType) {
+        final ChannelUID channelUID = getInputChannelUID(channelNumber, ioTaWattChannelType);
+        if (value != null) {
+            deviceHandlerCallback.addChannelIfNotExists(channelUID, ioTaWattChannelType);
+            deviceHandlerCallback.updateState(channelUID, new QuantityType<>(value, ioTaWattChannelType.unit));
+            // TODO removed channels are not in array anymore
+        }
+    }
+
+    private ChannelUID getInputChannelUID(int channelNumber, IoTaWattChannelType ioTaWattChannelType) {
+        return new ChannelUID(deviceHandlerCallback.getThingUID(),
+                INPUT_CHANNEL_ID_PREFIX + toTwoDigits(channelNumber) + "#" + ioTaWattChannelType.channelIdSuffix);
+    }
+
+    private String toTwoDigits(int value) {
+        return value < 10 ? ("0" + value) : String.valueOf(value);
+    }
+}
diff --git a/bundles/org.openhab.binding.iotawatt/src/main/resources/OH-INF/addon/addon.xml b/bundles/org.openhab.binding.iotawatt/src/main/resources/OH-INF/addon/addon.xml
new file mode 100644 (file)
index 0000000..7f0d476
--- /dev/null
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<addon:addon id="iotawatt" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:addon="https://openhab.org/schemas/addon/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/addon/v1.0.0 https://openhab.org/schemas/addon-1.0.0.xsd">
+
+       <type>binding</type>
+       <name>IoTaWatt Binding</name>
+       <description>This is the binding for IoTaWatt.</description>
+       <connection>local</connection>
+
+</addon:addon>
diff --git a/bundles/org.openhab.binding.iotawatt/src/main/resources/OH-INF/i18n/iotawatt.properties b/bundles/org.openhab.binding.iotawatt/src/main/resources/OH-INF/i18n/iotawatt.properties
new file mode 100644 (file)
index 0000000..42aed58
--- /dev/null
@@ -0,0 +1,42 @@
+# add-on
+
+addon.iotawatt.name = IoTaWatt Binding
+addon.iotawatt.description = This is the binding for IoTaWatt.
+
+# thing types
+
+thing-type.iotawatt.iotawatt.label = IoTaWatt Binding Thing
+thing-type.iotawatt.iotawatt.description = An IoTaWatt devices
+
+# thing types config
+
+thing-type.config.iotawatt.iotawatt.hostname.label = Hostname
+thing-type.config.iotawatt.iotawatt.hostname.description = Hostname or IP address of the device
+thing-type.config.iotawatt.iotawatt.refreshInterval.label = Refresh Interval
+thing-type.config.iotawatt.iotawatt.refreshInterval.description = Interval the device is polled in sec.
+thing-type.config.iotawatt.iotawatt.requestTimeout.label = Request timeout
+thing-type.config.iotawatt.iotawatt.requestTimeout.description = The request timeout to call the device in sec.
+
+# channel types
+
+channel-type.iotawatt.amps.label = Amps
+channel-type.iotawatt.amps.description = The current Amps.
+channel-type.iotawatt.apparent-power.label = Apparent Power
+channel-type.iotawatt.apparent-power.description = The current apparent power.
+channel-type.iotawatt.frequency.label = AC Frequency
+channel-type.iotawatt.frequency.description = The current AC frequency.
+channel-type.iotawatt.phase.label = Phase
+channel-type.iotawatt.phase.description = The current phase.
+channel-type.iotawatt.power-factor.label = Power Factor
+channel-type.iotawatt.power-factor.description = The current power factor.
+channel-type.iotawatt.reactive-power-hour.label = Reactive Power Hour
+channel-type.iotawatt.reactive-power-hour.description = The current reactive power hour.
+channel-type.iotawatt.reactive-power.label = Reactive Power
+channel-type.iotawatt.reactive-power.description = The current reactive power.
+channel-type.iotawatt.voltage.label = Voltage
+channel-type.iotawatt.voltage.description = The current voltage.
+channel-type.iotawatt.watts.label = Power Consumption
+channel-type.iotawatt.watts.description = The current power consumption.
+
+# channel types
+configuration-error = The configuration is wrong, please check if you configured a hostname/IP address and positive numbers for the timeout settings.
diff --git a/bundles/org.openhab.binding.iotawatt/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.iotawatt/src/main/resources/OH-INF/thing/thing-types.xml
new file mode 100644 (file)
index 0000000..0fe5822
--- /dev/null
@@ -0,0 +1,101 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="iotawatt"
+       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">
+
+       <!-- Thing Type -->
+       <thing-type id="iotawatt">
+               <label>IoTaWatt Binding Thing</label>
+               <description>An IoTaWatt devices</description>
+
+               <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="refreshInterval" type="integer" unit="s" min="1">
+                               <label>Refresh Interval</label>
+                               <description>Interval the device is polled in sec.</description>
+                               <default>10</default>
+                               <advanced>false</advanced>
+                       </parameter>
+                       <parameter name="requestTimeout" type="integer" unit="s" min="1">
+                               <label>Request timeout</label>
+                               <description>The request timeout to call the device in sec.</description>
+                               <default>10</default>
+                               <advanced>false</advanced>
+                       </parameter>
+                       <!-- run mvn i18n:generate-default-translations when updating the params -->
+               </config-description>
+       </thing-type>
+
+       <!-- Channel Types -->
+       <channel-type id="amps">
+               <item-type>Number:ElectricCurrent</item-type>
+               <label>Amps</label>
+               <description>The current Amps.</description>
+               <state pattern="%.2f %unit%" readOnly="true"/>
+       </channel-type>
+       <channel-type id="frequency">
+               <item-type>Number:Frequency</item-type>
+               <label>AC Frequency</label>
+               <description>The current AC frequency.</description>
+               <state pattern="%.2f %unit%" readOnly="true"/>
+       </channel-type>
+       <channel-type id="power-factor">
+               <item-type>Number:Dimensionless</item-type>
+               <label>Power Factor</label>
+               <description>The current power factor.</description>
+               <state pattern="%.2f" readOnly="true"/>
+       </channel-type>
+       <channel-type id="apparent-power">
+               <item-type>Number:Power</item-type>
+               <label>Apparent Power</label>
+               <description>The current apparent power.</description>
+               <state pattern="%.2f %unit%" readOnly="true"/>
+       </channel-type>
+       <channel-type id="reactive-power">
+               <item-type>Number:Power</item-type>
+               <label>Reactive Power</label>
+               <description>The current reactive power.</description>
+               <state pattern="%.2f %unit%" readOnly="true"/>
+       </channel-type>
+       <channel-type id="reactive-power-hour">
+               <item-type>Number:Energy</item-type>
+               <label>Reactive Power Hour</label>
+               <description>The current reactive power hour.</description>
+               <state pattern="%.2f %unit%" readOnly="true"/>
+       </channel-type>
+       <channel-type id="voltage">
+               <item-type>Number:ElectricPotential</item-type>
+               <label>Voltage</label>
+               <description>The current voltage.</description>
+               <category>Energy</category>
+               <tags>
+                       <tag>Measurement</tag>
+                       <tag>Voltage</tag>
+               </tags>
+               <state pattern="%.3f %unit%" readOnly="true">
+               </state>
+       </channel-type>
+       <channel-type id="watts">
+               <item-type>Number:Power</item-type>
+               <label>Power Consumption</label>
+               <description>The current power consumption.</description>
+               <category>Energy</category>
+               <tags>
+                       <tag>Measurement</tag>
+                       <tag>Power</tag>
+               </tags>
+               <state pattern="%.2f %unit%" readOnly="true">
+               </state>
+       </channel-type>
+       <channel-type id="phase">
+               <item-type>Number:Dimensionless</item-type>
+               <label>Phase</label>
+               <description>The current phase.</description>
+               <state pattern="%.2f" readOnly="true"/>
+       </channel-type>
+</thing:thing-descriptions>
diff --git a/bundles/org.openhab.binding.iotawatt/src/test/java/org/openhab/binding/iotawatt/internal/client/IoTaWattClientTest.java b/bundles/org.openhab.binding.iotawatt/src/test/java/org/openhab/binding/iotawatt/internal/client/IoTaWattClientTest.java
new file mode 100644 (file)
index 0000000..feac434
--- /dev/null
@@ -0,0 +1,216 @@
+/**
+ * Copyright (c) 2010-2024 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.iotawatt.internal.client;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Optional;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.stream.Stream;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.client.api.ContentResponse;
+import org.eclipse.jetty.client.api.Request;
+import org.eclipse.jetty.http.HttpMethod;
+import org.eclipse.jetty.http.HttpStatus;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.openhab.binding.iotawatt.internal.model.StatusResponse;
+
+import com.google.gson.Gson;
+
+/**
+ * @author Peter Rosenberg - Initial contribution
+ */
+@ExtendWith(MockitoExtension.class)
+@NonNullByDefault
+class IoTaWattClientTest {
+    private static final String DEVICE_STATUS_RESPONSE_FILE = "apiResponses/device-status-response.json";
+
+    @Mock
+    @NonNullByDefault({})
+    private HttpClient httpClient;
+    private final Gson gson = new Gson();
+
+    @Test
+    void fetchStatus_whenValidJson_returnObject() throws IOException, ExecutionException, InterruptedException,
+            TimeoutException, IoTaWattClientInterruptedException, IoTaWattClientCommunicationException,
+            IoTaWattClientConfigurationException, IoTaWattClientException {
+        // given
+        final IoTaWattClient client = new IoTaWattClient("hostname", 10, httpClient, gson);
+        Request request = mock(Request.class);
+        when(httpClient.newRequest(any(URI.class))).thenReturn(request);
+        when(request.method(any(HttpMethod.class))).thenReturn(request);
+        when(request.timeout(anyLong(), any(TimeUnit.class))).thenReturn(request);
+        ContentResponse contentResponse = mock(ContentResponse.class);
+        when(request.send()).thenReturn(contentResponse);
+        when(contentResponse.getStatus()).thenReturn(HttpStatus.OK_200);
+        when(contentResponse.getContentAsString()).thenReturn(readFile(DEVICE_STATUS_RESPONSE_FILE));
+
+        // when
+        Optional<StatusResponse> resultOptional = client.fetchStatus();
+
+        // then
+        // noinspection OptionalGetWithoutIsPresent
+        StatusResponse result = resultOptional.get();
+        assertThat(result.inputs().size(), is(2));
+        StatusResponse.Input input0 = result.inputs().get(0);
+        assertThat(input0.channel(), is(0));
+        assertThat(input0.vrms(), is(254.2972F));
+        assertThat(input0.hz(), is(50.02768F));
+        assertThat(input0.phase(), is(0.92F));
+        StatusResponse.Input input1 = result.inputs().get(1);
+        assertThat(input1.channel(), is(1));
+        assertThat(input1.watts(), is(1.42F));
+        assertThat(input1.phase(), is(2.2F));
+    }
+
+    @Test
+    void fetchStatus_whenWrongHost_throwException() {
+        // given
+        final IoTaWattClient client = new IoTaWattClient(" ", 10, httpClient, mock(Gson.class));
+
+        // when
+        assertThrows(IoTaWattClientConfigurationException.class, client::fetchStatus);
+    }
+
+    @Test
+    void fetchStatus_whenInputsAndOutputsEmpty_returnEmpty()
+            throws ExecutionException, InterruptedException, TimeoutException, IoTaWattClientInterruptedException,
+            IoTaWattClientCommunicationException, IoTaWattClientConfigurationException, IoTaWattClientException {
+        // given
+        final IoTaWattClient client = new IoTaWattClient("hostname", 10, httpClient, gson);
+        Request request = mock(Request.class);
+        when(httpClient.newRequest(any(URI.class))).thenReturn(request);
+        when(request.method(any(HttpMethod.class))).thenReturn(request);
+        when(request.timeout(anyLong(), any(TimeUnit.class))).thenReturn(request);
+        ContentResponse contentResponse = mock(ContentResponse.class);
+        when(request.send()).thenReturn(contentResponse);
+        when(contentResponse.getContentAsString()).thenReturn("{}");
+        when(contentResponse.getStatus()).thenReturn(HttpStatus.OK_200);
+
+        // when
+        Optional<StatusResponse> resultOptional = client.fetchStatus();
+
+        // then
+        // noinspection OptionalGetWithoutIsPresent
+        StatusResponse result = resultOptional.get();
+        assertNull(result.inputs());
+        assertNull(result.outputs());
+    }
+
+    @Test
+    void fetchStatus_whenNot200Response_throwsException()
+            throws ExecutionException, InterruptedException, TimeoutException {
+        // given
+        final IoTaWattClient client = new IoTaWattClient("hostname", 10, httpClient, gson);
+        Request request = mock(Request.class);
+        when(httpClient.newRequest(any(URI.class))).thenReturn(request);
+        when(request.method(any(HttpMethod.class))).thenReturn(request);
+        when(request.timeout(anyLong(), any(TimeUnit.class))).thenReturn(request);
+        ContentResponse contentResponse = mock(ContentResponse.class);
+        when(request.send()).thenReturn(contentResponse);
+        when(contentResponse.getStatus()).thenReturn(HttpStatus.BAD_REQUEST_400);
+
+        // when/then
+        assertThrows(IoTaWattClientCommunicationException.class, client::fetchStatus);
+    }
+
+    @ParameterizedTest
+    @MethodSource("provideParamsForThrowCases")
+    void fetchStatus_whenExceptions_throwsCustomException(Class<Throwable> thrownException,
+            Class<Throwable> expectedException) throws ExecutionException, InterruptedException, TimeoutException {
+        // given
+        final IoTaWattClient client = new IoTaWattClient("hostname", 10, httpClient, gson);
+        Request request = mock(Request.class);
+        when(httpClient.newRequest(any(URI.class))).thenReturn(request);
+        when(request.method(any(HttpMethod.class))).thenReturn(request);
+        when(request.timeout(anyLong(), any(TimeUnit.class))).thenReturn(request);
+        when(request.send()).thenThrow(thrownException);
+
+        // when/then
+        assertThrows(expectedException, client::fetchStatus);
+    }
+
+    @Test
+    void start_whenSuccess_noException() {
+        // given
+        final IoTaWattClient client = new IoTaWattClient("hostname", 10, httpClient, gson);
+        // when
+        client.start();
+        // then
+        // doesn't throw an exception
+    }
+
+    @Test
+    void start_whenError_throwException() throws Exception {
+        // given
+        final IoTaWattClient client = new IoTaWattClient("hostname", 10, httpClient, gson);
+        doThrow(Exception.class).when(httpClient).start();
+        // when/then
+        assertThrows(IllegalStateException.class, client::start);
+    }
+
+    @Test
+    void stop_whenSuccess_noException() {
+        // given
+        final IoTaWattClient client = new IoTaWattClient("hostname", 10, httpClient, gson);
+        // when
+        client.stop();
+        // then
+        // doesn't throw an exception
+    }
+
+    @Test
+    void stop_whenError_noException() throws Exception {
+        // given
+        final IoTaWattClient client = new IoTaWattClient("hostname", 10, httpClient, gson);
+        doThrow(Exception.class).when(httpClient).stop();
+        // when
+        client.stop();
+        // then
+        // doesn't throw an exception
+    }
+
+    private static Stream<Arguments> provideParamsForThrowCases() {
+        return Stream.of(Arguments.of(InterruptedException.class, IoTaWattClientInterruptedException.class),
+                Arguments.of(TimeoutException.class, IoTaWattClientCommunicationException.class),
+                Arguments.of(ExecutionException.class, IoTaWattClientException.class));
+    }
+
+    private String readFile(String filename) throws IOException {
+        final Path workingDir = Path.of("", "src/test/resources");
+        final Path file = workingDir.resolve(filename);
+        return Files.readString(file);
+    }
+}
diff --git a/bundles/org.openhab.binding.iotawatt/src/test/java/org/openhab/binding/iotawatt/internal/model/IoTaWattChannelTypeTest.java b/bundles/org.openhab.binding.iotawatt/src/test/java/org/openhab/binding/iotawatt/internal/model/IoTaWattChannelTypeTest.java
new file mode 100644 (file)
index 0000000..6a4677e
--- /dev/null
@@ -0,0 +1,35 @@
+/**
+ * Copyright (c) 2010-2024 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.iotawatt.internal.model;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+
+/**
+ * @author Peter Rosenberg - Initial contribution
+ */
+@NonNullByDefault
+class IoTaWattChannelTypeTest {
+
+    @Test
+    void valueOf_whenUnknownValue_thenThrowException() {
+        // given
+        final String unknownValue = "unknownValue";
+
+        // when/then
+        // noinspection ResultOfMethodCallIgnored
+        assertThrows(IllegalArgumentException.class, () -> IoTaWattChannelType.fromOutputUnits(unknownValue));
+    }
+}
diff --git a/bundles/org.openhab.binding.iotawatt/src/test/java/org/openhab/binding/iotawatt/internal/service/FetchDataServiceTest.java b/bundles/org.openhab.binding.iotawatt/src/test/java/org/openhab/binding/iotawatt/internal/service/FetchDataServiceTest.java
new file mode 100644 (file)
index 0000000..f792e75
--- /dev/null
@@ -0,0 +1,251 @@
+/**
+ * Copyright (c) 2010-2024 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.iotawatt.internal.service;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.openhab.binding.iotawatt.internal.service.FetchDataService.INPUT_CHANNEL_ID_PREFIX;
+import static org.openhab.binding.iotawatt.internal.service.FetchDataService.OUTPUT_CHANNEL_ID_PREFIX;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Stream;
+
+import javax.measure.Unit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.openhab.binding.iotawatt.internal.IoTaWattBindingConstants;
+import org.openhab.binding.iotawatt.internal.client.IoTaWattClient;
+import org.openhab.binding.iotawatt.internal.client.IoTaWattClientCommunicationException;
+import org.openhab.binding.iotawatt.internal.client.IoTaWattClientConfigurationException;
+import org.openhab.binding.iotawatt.internal.client.IoTaWattClientException;
+import org.openhab.binding.iotawatt.internal.client.IoTaWattClientInterruptedException;
+import org.openhab.binding.iotawatt.internal.model.StatusResponse;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.unit.Units;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.ThingUID;
+import org.openhab.core.types.State;
+
+/**
+ * @author Peter Rosenberg - Initial contribution
+ */
+@ExtendWith(MockitoExtension.class)
+@NonNullByDefault
+class FetchDataServiceTest {
+    @Mock
+    @NonNullByDefault({})
+    private DeviceHandlerCallback deviceHandlerCallback;
+    @Mock
+    @NonNullByDefault({})
+    private IoTaWattClient ioTaWattClient;
+    @InjectMocks
+    @NonNullByDefault({})
+    private FetchDataService service;
+
+    private final ThingUID thingUID = new ThingUID(IoTaWattBindingConstants.BINDING_ID, "d231dea2e4");
+
+    @Test
+    void pollDevice_whenAllSupportedInputTypes_updateAllChannels() throws IoTaWattClientInterruptedException,
+            IoTaWattClientCommunicationException, IoTaWattClientConfigurationException, IoTaWattClientException {
+        // given
+        service.setIoTaWattClient(ioTaWattClient);
+        final Float voltageRms = 259.1f;
+        final Float hertz = 50.1f;
+        final Float phase0 = 0.1f;
+        final Float wattsValue = 1.1f;
+        final Float phase1 = 0.2f;
+        final Float powerFactor = 0.3f;
+        when(deviceHandlerCallback.getThingUID()).thenReturn(thingUID);
+        final List<StatusResponse.Input> inputs = List.of(
+                new StatusResponse.Input(0, voltageRms, hertz, phase0, null, null),
+                new StatusResponse.Input(1, null, null, phase1, wattsValue, powerFactor));
+        final StatusResponse statusResponse = new StatusResponse(inputs, List.of());
+        when(ioTaWattClient.fetchStatus()).thenReturn(Optional.of(statusResponse));
+
+        // when
+        service.pollDevice();
+
+        // then
+        verify(deviceHandlerCallback).updateStatus(ThingStatus.ONLINE);
+        verify(deviceHandlerCallback).updateState(createInputChannelUID("00", "voltage"),
+                createState(voltageRms, Units.VOLT));
+        verify(deviceHandlerCallback).updateState(createInputChannelUID("00", "frequency"),
+                createState(hertz, Units.HERTZ));
+        verify(deviceHandlerCallback).updateState(createInputChannelUID("00", "phase"), createState(phase0, Units.ONE));
+        verify(deviceHandlerCallback).updateState(createInputChannelUID("01", "watts"),
+                createState(wattsValue, Units.WATT));
+        verify(deviceHandlerCallback).updateState(createInputChannelUID("01", "phase"), createState(phase1, Units.ONE));
+        verify(deviceHandlerCallback).updateState(createInputChannelUID("01", "power-factor"),
+                createState(powerFactor, Units.ONE));
+    }
+
+    @Test
+    void pollDevice_whenAllSupportedOutputTypes_updateAllChannels() throws IoTaWattClientInterruptedException,
+            IoTaWattClientCommunicationException, IoTaWattClientConfigurationException, IoTaWattClientException {
+        // given
+        service.setIoTaWattClient(ioTaWattClient);
+        when(deviceHandlerCallback.getThingUID()).thenReturn(thingUID);
+        final List<StatusResponse.Output> outputs = List.of(new StatusResponse.Output("name_amps", "Amps", 1.01f),
+                new StatusResponse.Output("name_hz", "Hz", 1.02f), new StatusResponse.Output("name_pf", "PF", 1.03f),
+                new StatusResponse.Output("name_va", "VA", 1.04f), new StatusResponse.Output("name_var", "VAR", 1.05f),
+                new StatusResponse.Output("name_varh", "VARh", 1.06f),
+                new StatusResponse.Output("name_volts", "Volts", 1.07f),
+                new StatusResponse.Output("name_watts", "Watts", 1.08f));
+        final StatusResponse statusResponse = new StatusResponse(List.of(), outputs);
+        when(ioTaWattClient.fetchStatus()).thenReturn(Optional.of(statusResponse));
+
+        // when
+        service.pollDevice();
+
+        // then
+        verify(deviceHandlerCallback).updateStatus(ThingStatus.ONLINE);
+        verify(deviceHandlerCallback).updateState(createOutputChannelUID("00", "name_amps"),
+                createState(1.01f, Units.AMPERE));
+        verify(deviceHandlerCallback).updateState(createOutputChannelUID("01", "name_hz"),
+                createState(1.02f, Units.HERTZ));
+        verify(deviceHandlerCallback).updateState(createOutputChannelUID("02", "name_pf"),
+                createState(1.03f, Units.ONE));
+        verify(deviceHandlerCallback).updateState(createOutputChannelUID("03", "name_va"),
+                createState(1.04f, Units.VOLT_AMPERE));
+        verify(deviceHandlerCallback).updateState(createOutputChannelUID("04", "name_var"),
+                createState(1.05f, Units.VAR));
+        verify(deviceHandlerCallback).updateState(createOutputChannelUID("05", "name_varh"),
+                createState(1.06f, Units.VAR_HOUR));
+        verify(deviceHandlerCallback).updateState(createOutputChannelUID("06", "name_volts"),
+                createState(1.07f, Units.VOLT));
+        verify(deviceHandlerCallback).updateState(createOutputChannelUID("07", "name_watts"),
+                createState(1.08f, Units.WATT));
+    }
+
+    @Test
+    void pollDevice_whenResponseWithNoChannels_updateStatusToOnlineAndDoNotUpdateChannels()
+            throws IoTaWattClientInterruptedException, IoTaWattClientCommunicationException,
+            IoTaWattClientConfigurationException, IoTaWattClientException {
+        // given
+        service.setIoTaWattClient(ioTaWattClient);
+        when(ioTaWattClient.fetchStatus()).thenReturn(Optional.empty());
+
+        // when
+        service.pollDevice();
+
+        // then
+        verify(deviceHandlerCallback).updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
+        verify(deviceHandlerCallback, never()).updateState(any(), any());
+    }
+
+    @Test
+    void pollDevice_whenExceptionWithCase_useCauseMessage() throws IoTaWattClientInterruptedException,
+            IoTaWattClientCommunicationException, IoTaWattClientConfigurationException, IoTaWattClientException {
+        // given
+        final String exceptionMessage = "test message";
+        service.setIoTaWattClient(ioTaWattClient);
+        final Throwable exception = new IoTaWattClientConfigurationException(new Throwable(exceptionMessage));
+        when(ioTaWattClient.fetchStatus()).thenThrow(exception);
+
+        // when
+        service.pollDevice();
+
+        // then
+        verify(deviceHandlerCallback).updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+                exceptionMessage);
+        verify(deviceHandlerCallback, never()).updateState(any(), any());
+    }
+
+    @Test
+    void pollDevice_whenEmptyResponse_updateStatusToOffline() {
+        // given
+        // do not set service.setIoTaWattClient(ioTaWattClient);
+
+        // when
+        service.pollDevice();
+
+        // then
+        verify(deviceHandlerCallback).updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.HANDLER_INITIALIZING_ERROR);
+        verify(deviceHandlerCallback, never()).updateState(any(), any());
+    }
+
+    @Test
+    void pollDevice_whenNotInitialised_fail() throws IoTaWattClientInterruptedException,
+            IoTaWattClientCommunicationException, IoTaWattClientConfigurationException, IoTaWattClientException {
+        // given
+        service.setIoTaWattClient(ioTaWattClient);
+        final StatusResponse statusResponse = new StatusResponse(List.of(), List.of());
+        when(ioTaWattClient.fetchStatus()).thenReturn(Optional.of(statusResponse));
+
+        // when
+        service.pollDevice();
+
+        // then
+        verify(deviceHandlerCallback).updateStatus(ThingStatus.ONLINE);
+        verify(deviceHandlerCallback, never()).updateState(any(), any());
+    }
+
+    @ParameterizedTest
+    @MethodSource("provideParamsForThrowCases")
+    void pollDevice_whenApiRequestThrowsInterruptedException_updateStatusAccordingly(Class<Throwable> throwableClass,
+            ThingStatusDetail thingStatusDetail, boolean withErrorMessage) throws IoTaWattClientInterruptedException,
+            IoTaWattClientCommunicationException, IoTaWattClientConfigurationException, IoTaWattClientException {
+        // given
+        final String errorMessage = "Error message";
+        service.setIoTaWattClient(ioTaWattClient);
+        final Throwable thrownThrowable = mock(throwableClass);
+        if (withErrorMessage) {
+            when(thrownThrowable.getMessage()).thenReturn(errorMessage);
+        }
+        when(ioTaWattClient.fetchStatus()).thenThrow(thrownThrowable);
+
+        // when
+        service.pollDevice();
+
+        // then
+        if (withErrorMessage) {
+            verify(deviceHandlerCallback).updateStatus(ThingStatus.OFFLINE, thingStatusDetail, errorMessage);
+        } else {
+            verify(deviceHandlerCallback).updateStatus(ThingStatus.OFFLINE, thingStatusDetail);
+        }
+        verify(deviceHandlerCallback, never()).updateState(any(), any());
+    }
+
+    private static Stream<Arguments> provideParamsForThrowCases() {
+        return Stream.of(Arguments.of(IoTaWattClientInterruptedException.class, ThingStatusDetail.NOT_YET_READY, false),
+                Arguments.of(IoTaWattClientCommunicationException.class, ThingStatusDetail.COMMUNICATION_ERROR, false),
+                Arguments.of(IoTaWattClientConfigurationException.class, ThingStatusDetail.CONFIGURATION_ERROR, true),
+                Arguments.of(IoTaWattClientException.class, ThingStatusDetail.NONE, true));
+    }
+
+    private ChannelUID createInputChannelUID(String channelNumberStr, String channelName) {
+        return new ChannelUID(thingUID, INPUT_CHANNEL_ID_PREFIX + channelNumberStr + "#" + channelName);
+    }
+
+    private ChannelUID createOutputChannelUID(String channelNumber, String channelName) {
+        return new ChannelUID(thingUID, OUTPUT_CHANNEL_ID_PREFIX + channelNumber + "#" + channelName);
+    }
+
+    private State createState(Float value, Unit<?> unit) {
+        return new QuantityType<>(value, unit);
+    }
+}
diff --git a/bundles/org.openhab.binding.iotawatt/src/test/resources/apiResponses/device-status-response.json b/bundles/org.openhab.binding.iotawatt/src/test/resources/apiResponses/device-status-response.json
new file mode 100644 (file)
index 0000000..bccd276
--- /dev/null
@@ -0,0 +1,106 @@
+{
+  "stats": {
+    "cyclerate": 771.9443,
+    "chanrate": 32.45549,
+    "starttime": 1708508856,
+    "currenttime": 1710326560,
+    "runseconds": 1817704,
+    "stack": 24472,
+    "version": "02_08_03",
+    "frequency": 49.97682,
+    "lowbat": false
+  },
+  "inputs": [
+    {
+      "channel": 0,
+      "Vrms": 254.2972,
+      "Hz": 50.02768,
+      "phase": 0.92
+    },
+    {
+      "channel": 1,
+      "Watts": 1.42,
+      "Pf": 0,
+      "phase": 2.2,
+      "lastphase": 1.28
+    }
+  ],
+  "outputs": [
+    {
+      "name": "Input_1_amps",
+      "units": "Amps",
+      "value": 0.106694
+    },
+    {
+      "name": "Input_1_hz",
+      "units": "Hz",
+      "value": 49.96615
+    },
+    {
+      "name": "Input_1_pf",
+      "units": "PF",
+      "value": 0
+    },
+    {
+      "name": "Input_1_va",
+      "units": "VA",
+      "value": 26.28139
+    },
+    {
+      "name": "Input_1_var",
+      "units": "VAR",
+      "value": 26.28139
+    },
+    {
+      "name": "Input_1_varh",
+      "units": "VARh",
+      "value": 26.28139
+    },
+    {
+      "name": "Input_1_volts",
+      "units": "Volts",
+      "value": 246.3257
+    },
+    {
+      "name": "Input_1_watts",
+      "units": "Watts",
+      "value": 0
+    }
+  ],
+  "influx1": {
+    "state": "not running"
+  },
+  "influx2": {
+    "state": "not running"
+  },
+  "emoncms": {
+    "state": "not running"
+  },
+  "pvoutput": {
+    "state": "not running"
+  },
+  "datalogs": [
+    {
+      "id": "Current",
+      "firstkey": 1707199250,
+      "lastkey": 1710326560,
+      "size": 152183040,
+      "interval": 5
+    },
+    {
+      "id": "History",
+      "firstkey": 1707199260,
+      "lastkey": 1710326520,
+      "size": 12699648,
+      "interval": 60
+    }
+  ],
+  "wifi": {
+    "connecttime": 1707993027,
+    "SSID": "mywifi",
+    "IP": "192.168.1.2",
+    "channel": 6,
+    "RSSI": -60,
+    "mac": "AA:BB:CC:DD:EE:AA"
+  }
+}
index 6b14574889ed821b9b79836af55ee48bb0515ffb..39d7e4e2ef197f71a1e01044ff91d4467cf82d09 100644 (file)
     <module>org.openhab.binding.ipcamera</module>
     <module>org.openhab.binding.ipobserver</module>
     <module>org.openhab.binding.intesis</module>
+    <module>org.openhab.binding.iotawatt</module>
     <module>org.openhab.binding.ipp</module>
     <module>org.openhab.binding.irobot</module>
     <module>org.openhab.binding.irtrans</module>