/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
<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>
--- /dev/null
+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
--- /dev/null
+# 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" }
+```
--- /dev/null
+<?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>
--- /dev/null
+<?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>
--- /dev/null
+/**
+ * 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");
+}
--- /dev/null
+/**
+ * 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;
+ }
+}
--- /dev/null
+/**
+ * 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();
+ }
+}
--- /dev/null
+/**
+ * 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();
+ }
+ }
+}
--- /dev/null
+/**
+ * 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);
+ }
+}
--- /dev/null
+/**
+ * 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);
+ }
+}
--- /dev/null
+/**
+ * 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);
+ }
+}
--- /dev/null
+/**
+ * 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;
+}
--- /dev/null
+/**
+ * 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);
+}
--- /dev/null
+/**
+ * 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();
+}
--- /dev/null
+/**
+ * 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);
+}
--- /dev/null
+/**
+ * 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());
+ }
+ }
+}
--- /dev/null
+/**
+ * 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);
+ };
+ }
+}
--- /dev/null
+/**
+ * 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) {
+ }
+}
--- /dev/null
+/**
+ * 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);
+}
--- /dev/null
+/**
+ * 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);
+ }
+}
--- /dev/null
+<?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>
--- /dev/null
+# 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.
--- /dev/null
+<?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>
--- /dev/null
+/**
+ * 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);
+ }
+}
--- /dev/null
+/**
+ * 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));
+ }
+}
--- /dev/null
+/**
+ * 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);
+ }
+}
--- /dev/null
+{
+ "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"
+ }
+}
<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>