From a4ad7b27b7e081d86f9caebfda7c6e550299d66b Mon Sep 17 00:00:00 2001 From: Pete <8108165+PRosenb@users.noreply.github.com> Date: Wed, 29 May 2024 05:37:59 +1000 Subject: [PATCH] [iotawatt] Initial contribution (#16491) * [iotawatt] generate new binding Signed-off-by: Peter Rosenberg --- CODEOWNERS | 1 + bom/openhab-addons/pom.xml | 5 + bundles/org.openhab.binding.iotawatt/NOTICE | 13 + .../org.openhab.binding.iotawatt/README.md | 86 ++++++ bundles/org.openhab.binding.iotawatt/pom.xml | 25 ++ .../src/main/feature/feature.xml | 9 + .../internal/IoTaWattBindingConstants.java | 35 +++ .../internal/IoTaWattConfiguration.java | 65 +++++ .../internal/IoTaWattHandlerFactory.java | 97 +++++++ .../internal/client/IoTaWattClient.java | 130 +++++++++ .../IoTaWattClientCommunicationException.java | 32 +++ .../IoTaWattClientConfigurationException.java | 32 +++ .../client/IoTaWattClientException.java | 32 +++ .../IoTaWattClientInterruptedException.java | 25 ++ .../handler/FetchDataServiceProvider.java | 33 +++ .../internal/handler/HttpClientProvider.java | 31 +++ .../handler/IoTaWattClientProvider.java | 32 +++ .../internal/handler/IoTaWattHandler.java | 144 ++++++++++ .../internal/model/IoTaWattChannelType.java | 115 ++++++++ .../internal/model/StatusResponse.java | 53 ++++ .../service/DeviceHandlerCallback.java | 76 ++++++ .../internal/service/FetchDataService.java | 147 ++++++++++ .../src/main/resources/OH-INF/addon/addon.xml | 11 + .../resources/OH-INF/i18n/iotawatt.properties | 42 +++ .../resources/OH-INF/thing/thing-types.xml | 101 +++++++ .../internal/client/IoTaWattClientTest.java | 216 +++++++++++++++ .../model/IoTaWattChannelTypeTest.java | 35 +++ .../service/FetchDataServiceTest.java | 251 ++++++++++++++++++ .../apiResponses/device-status-response.json | 106 ++++++++ bundles/pom.xml | 1 + 30 files changed, 1981 insertions(+) create mode 100644 bundles/org.openhab.binding.iotawatt/NOTICE create mode 100644 bundles/org.openhab.binding.iotawatt/README.md create mode 100644 bundles/org.openhab.binding.iotawatt/pom.xml create mode 100644 bundles/org.openhab.binding.iotawatt/src/main/feature/feature.xml create mode 100644 bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/IoTaWattBindingConstants.java create mode 100644 bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/IoTaWattConfiguration.java create mode 100644 bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/IoTaWattHandlerFactory.java create mode 100644 bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/client/IoTaWattClient.java create mode 100644 bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/client/IoTaWattClientCommunicationException.java create mode 100644 bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/client/IoTaWattClientConfigurationException.java create mode 100644 bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/client/IoTaWattClientException.java create mode 100644 bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/client/IoTaWattClientInterruptedException.java create mode 100644 bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/handler/FetchDataServiceProvider.java create mode 100644 bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/handler/HttpClientProvider.java create mode 100644 bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/handler/IoTaWattClientProvider.java create mode 100644 bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/handler/IoTaWattHandler.java create mode 100644 bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/model/IoTaWattChannelType.java create mode 100644 bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/model/StatusResponse.java create mode 100644 bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/service/DeviceHandlerCallback.java create mode 100644 bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/service/FetchDataService.java create mode 100644 bundles/org.openhab.binding.iotawatt/src/main/resources/OH-INF/addon/addon.xml create mode 100644 bundles/org.openhab.binding.iotawatt/src/main/resources/OH-INF/i18n/iotawatt.properties create mode 100644 bundles/org.openhab.binding.iotawatt/src/main/resources/OH-INF/thing/thing-types.xml create mode 100644 bundles/org.openhab.binding.iotawatt/src/test/java/org/openhab/binding/iotawatt/internal/client/IoTaWattClientTest.java create mode 100644 bundles/org.openhab.binding.iotawatt/src/test/java/org/openhab/binding/iotawatt/internal/model/IoTaWattChannelTypeTest.java create mode 100644 bundles/org.openhab.binding.iotawatt/src/test/java/org/openhab/binding/iotawatt/internal/service/FetchDataServiceTest.java create mode 100644 bundles/org.openhab.binding.iotawatt/src/test/resources/apiResponses/device-status-response.json diff --git a/CODEOWNERS b/CODEOWNERS index 4bba2703041..fbe2c959274 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -162,6 +162,7 @@ /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 diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml index 839603965c4..ae6156a70c0 100644 --- a/bom/openhab-addons/pom.xml +++ b/bom/openhab-addons/pom.xml @@ -806,6 +806,11 @@ org.openhab.binding.intesis ${project.version} + + org.openhab.addons.bundles + org.openhab.binding.iotawatt + ${project.version} + org.openhab.addons.bundles org.openhab.binding.ipcamera diff --git a/bundles/org.openhab.binding.iotawatt/NOTICE b/bundles/org.openhab.binding.iotawatt/NOTICE new file mode 100644 index 00000000000..38d625e3492 --- /dev/null +++ b/bundles/org.openhab.binding.iotawatt/NOTICE @@ -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 index 00000000000..3f1dace0b38 --- /dev/null +++ b/bundles/org.openhab.binding.iotawatt/README.md @@ -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 index 00000000000..609e15590f3 --- /dev/null +++ b/bundles/org.openhab.binding.iotawatt/pom.xml @@ -0,0 +1,25 @@ + + + + 4.0.0 + + + org.openhab.addons.bundles + org.openhab.addons.reactor.bundles + 4.2.0-SNAPSHOT + + + org.openhab.binding.iotawatt + + openHAB Add-ons :: Bundles :: IoTaWatt Binding + + + + org.mockito + mockito-core + 5.11.0 + test + + + 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 index 00000000000..97902fed9cc --- /dev/null +++ b/bundles/org.openhab.binding.iotawatt/src/main/feature/feature.xml @@ -0,0 +1,9 @@ + + + mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features + + + openhab-runtime-base + mvn:org.openhab.addons.bundles/org.openhab.binding.iotawatt/${project.version} + + 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 index 00000000000..dced823a3e2 --- /dev/null +++ b/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/IoTaWattBindingConstants.java @@ -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 index 00000000000..98647feed58 --- /dev/null +++ b/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/IoTaWattConfiguration.java @@ -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 index 00000000000..82f74ae3685 --- /dev/null +++ b/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/IoTaWattHandlerFactory.java @@ -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 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 index 00000000000..a6df365f878 --- /dev/null +++ b/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/client/IoTaWattClient.java @@ -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 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 index 00000000000..e882df25511 --- /dev/null +++ b/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/client/IoTaWattClientCommunicationException.java @@ -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 index 00000000000..2bd5ffa9648 --- /dev/null +++ b/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/client/IoTaWattClientConfigurationException.java @@ -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 index 00000000000..1832907eefe --- /dev/null +++ b/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/client/IoTaWattClientException.java @@ -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 index 00000000000..bbf4de1db59 --- /dev/null +++ b/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/client/IoTaWattClientInterruptedException.java @@ -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 index 00000000000..dbe934eb292 --- /dev/null +++ b/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/handler/FetchDataServiceProvider.java @@ -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 index 00000000000..766692bc1c9 --- /dev/null +++ b/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/handler/HttpClientProvider.java @@ -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 index 00000000000..1c5a96492a8 --- /dev/null +++ b/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/handler/IoTaWattClientProvider.java @@ -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 index 00000000000..77398b69a7f --- /dev/null +++ b/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/handler/IoTaWattHandler.java @@ -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 index 00000000000..15f00272668 --- /dev/null +++ b/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/model/IoTaWattChannelType.java @@ -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 index 00000000000..ffe08510885 --- /dev/null +++ b/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/model/StatusResponse.java @@ -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 inputs, @Nullable List 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 index 00000000000..9c119d1ec8a --- /dev/null +++ b/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/service/DeviceHandlerCallback.java @@ -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 index 00000000000..e5123f7895c --- /dev/null +++ b/bundles/org.openhab.binding.iotawatt/src/main/java/org/openhab/binding/iotawatt/internal/service/FetchDataService.java @@ -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 = 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 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 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 index 00000000000..7f0d4765383 --- /dev/null +++ b/bundles/org.openhab.binding.iotawatt/src/main/resources/OH-INF/addon/addon.xml @@ -0,0 +1,11 @@ + + + + binding + IoTaWatt Binding + This is the binding for IoTaWatt. + local + + 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 index 00000000000..42aed58ac90 --- /dev/null +++ b/bundles/org.openhab.binding.iotawatt/src/main/resources/OH-INF/i18n/iotawatt.properties @@ -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 index 00000000000..0fe58223679 --- /dev/null +++ b/bundles/org.openhab.binding.iotawatt/src/main/resources/OH-INF/thing/thing-types.xml @@ -0,0 +1,101 @@ + + + + + + + An IoTaWatt devices + + + + network-address + + Hostname or IP address of the device + + + + Interval the device is polled in sec. + 10 + false + + + + The request timeout to call the device in sec. + 10 + false + + + + + + + + Number:ElectricCurrent + + The current Amps. + + + + Number:Frequency + + The current AC frequency. + + + + Number:Dimensionless + + The current power factor. + + + + Number:Power + + The current apparent power. + + + + Number:Power + + The current reactive power. + + + + Number:Energy + + The current reactive power hour. + + + + Number:ElectricPotential + + The current voltage. + Energy + + Measurement + Voltage + + + + + + Number:Power + + The current power consumption. + Energy + + Measurement + Power + + + + + + Number:Dimensionless + + The current phase. + + + 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 index 00000000000..feac43449dd --- /dev/null +++ b/bundles/org.openhab.binding.iotawatt/src/test/java/org/openhab/binding/iotawatt/internal/client/IoTaWattClientTest.java @@ -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 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 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 thrownException, + Class 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 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 index 00000000000..6a4677e3e1f --- /dev/null +++ b/bundles/org.openhab.binding.iotawatt/src/test/java/org/openhab/binding/iotawatt/internal/model/IoTaWattChannelTypeTest.java @@ -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 index 00000000000..f792e7505a2 --- /dev/null +++ b/bundles/org.openhab.binding.iotawatt/src/test/java/org/openhab/binding/iotawatt/internal/service/FetchDataServiceTest.java @@ -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 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 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 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 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 index 00000000000..bccd2762d2b --- /dev/null +++ b/bundles/org.openhab.binding.iotawatt/src/test/resources/apiResponses/device-status-response.json @@ -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" + } +} diff --git a/bundles/pom.xml b/bundles/pom.xml index 6b14574889e..39d7e4e2ef1 100644 --- a/bundles/pom.xml +++ b/bundles/pom.xml @@ -197,6 +197,7 @@ org.openhab.binding.ipcamera org.openhab.binding.ipobserver org.openhab.binding.intesis + org.openhab.binding.iotawatt org.openhab.binding.ipp org.openhab.binding.irobot org.openhab.binding.irtrans