[iotawatt] Initial contribution (#16491)

* [iotawatt] generate new binding

Signed-off-by: Peter Rosenberg <prosenb.dev@gmail.com>
Signed-off-by: Ciprian Pascu <contact@ciprianpascu.ro>
This commit is contained in:
Pete 2024-05-29 05:37:59 +10:00 committed by Ciprian Pascu
parent 6f9308d0c5
commit 62fb411e73
30 changed files with 1981 additions and 0 deletions

View File

@ -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

View File

@ -806,6 +806,11 @@
<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>

View File

@ -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

View File

@ -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" }
```

View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
<version>4.2.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.binding.iotawatt</artifactId>
<name>openHAB Add-ons :: Bundles :: IoTaWatt Binding</name>
<dependencies>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.11.0</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<features name="org.openhab.binding.iotawatt-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
<repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
<feature name="openhab-binding-iotawatt" description="IoTaWatt Binding" version="${project.version}">
<feature>openhab-runtime-base</feature>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.iotawatt/${project.version}</bundle>
</feature>
</features>

View File

@ -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");
}

View File

@ -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;
}
}

View File

@ -0,0 +1,97 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.iotawatt.internal;
import static org.openhab.binding.iotawatt.internal.IoTaWattBindingConstants.THING_TYPE_IOTAWATT;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.util.ssl.SslContextFactory;
import org.openhab.binding.iotawatt.internal.client.IoTaWattClient;
import org.openhab.binding.iotawatt.internal.handler.FetchDataServiceProvider;
import org.openhab.binding.iotawatt.internal.handler.HttpClientProvider;
import org.openhab.binding.iotawatt.internal.handler.IoTaWattClientProvider;
import org.openhab.binding.iotawatt.internal.handler.IoTaWattHandler;
import org.openhab.binding.iotawatt.internal.service.DeviceHandlerCallback;
import org.openhab.binding.iotawatt.internal.service.FetchDataService;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
import org.openhab.core.thing.binding.ThingHandler;
import org.openhab.core.thing.binding.ThingHandlerFactory;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Deactivate;
import com.google.gson.Gson;
/**
* The {@link IoTaWattHandlerFactory} is responsible for creating things and thing
* handlers.
*
* @author Peter Rosenberg - Initial contribution
*/
@NonNullByDefault
@Component(configurationPid = "binding.iotawatt", service = ThingHandlerFactory.class)
public class IoTaWattHandlerFactory extends BaseThingHandlerFactory
implements HttpClientProvider, IoTaWattClientProvider, FetchDataServiceProvider {
private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_IOTAWATT);
private final HttpClient insecureClient;
private final Gson gson = new Gson();
/**
* Creates a IoTaWattHandlerFactory
*/
public IoTaWattHandlerFactory() {
this.insecureClient = new HttpClient(new SslContextFactory.Client(true));
}
@Override
public boolean supportsThingType(ThingTypeUID thingTypeUID) {
return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
}
@Override
protected @Nullable ThingHandler createHandler(Thing thing) {
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
if (THING_TYPE_IOTAWATT.equals(thingTypeUID)) {
return new IoTaWattHandler(thing, this, this);
}
return null;
}
@Override
public HttpClient getInsecureClient() {
return insecureClient;
}
@Override
public IoTaWattClient getIoTaWattClient(String hostname, long requestTimeout) {
return new IoTaWattClient(hostname, requestTimeout, insecureClient, gson);
}
@Override
public FetchDataService getFetchDataService(DeviceHandlerCallback deviceHandlerCallback) {
return new FetchDataService(deviceHandlerCallback);
}
@Deactivate
public void deactivate() {
insecureClient.destroy();
}
}

View File

@ -0,0 +1,130 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.iotawatt.internal.client;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.client.api.Request;
import org.eclipse.jetty.http.HttpMethod;
import org.eclipse.jetty.http.HttpStatus;
import org.openhab.binding.iotawatt.internal.model.StatusResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
/**
* Encapsulates the communication with the IoTaWatt device.
*
* @author Peter Rosenberg - Initial contribution
*/
@NonNullByDefault
public class IoTaWattClient {
private static final String REQUEST_URL = "http://%s/status?state=&inputs=&outputs=";
private final Logger logger = LoggerFactory.getLogger(IoTaWattClient.class);
/**
* The hostname the IoTaWattClient connects to
*/
public final String hostname;
private final long requestTimeout;
private final HttpClient httpClient;
private final Gson gson;
/**
* Creates an IoTaWattClient
*
* @param hostname The hostname of the IoTaWatt device to connect to
* @param httpClient The HttpClient to use
* @param gson The Gson decoder to use
*/
public IoTaWattClient(String hostname, long requestTimeout, HttpClient httpClient, Gson gson) {
this.httpClient = httpClient;
this.requestTimeout = requestTimeout;
this.hostname = hostname;
this.gson = gson;
}
public void start() {
try {
httpClient.start();
} catch (Exception e) {
// catching exception is necessary due to the signature of HttpClient.start()
logger.warn("Failed to start http client: {}", e.getMessage());
throw new IllegalStateException("Could not create HttpClient", e);
}
}
public void stop() {
try {
httpClient.stop();
} catch (Exception e) {
// catching exception is necessary due to the signature of HttpClient.stop()
logger.warn("Failed to stop http client: {}", e.getMessage());
}
}
/**
* Fetch the current status from the device.
* The errors are handled by the caller to update the Thing status accordingly.
*
* @throws IoTaWattClientCommunicationException On communication errors
* @throws IoTaWattClientInterruptedException When sending the request is interrupted
* @throws IoTaWattClientConfigurationException When the URI is wrong
* @throws IoTaWattClientException When an unknown error occurs
* @return The optional StatusResponse fetched from the device
*/
public Optional<StatusResponse> fetchStatus() throws IoTaWattClientCommunicationException,
IoTaWattClientInterruptedException, IoTaWattClientException, IoTaWattClientConfigurationException {
try {
final URI uri = new URI(String.format(REQUEST_URL, hostname));
final Request request = httpClient.newRequest(uri).method(HttpMethod.GET).timeout(requestTimeout,
TimeUnit.SECONDS);
final ContentResponse response = request.send();
if (response.getStatus() != HttpStatus.OK_200) {
throw new IoTaWattClientCommunicationException("HttpStatus " + response.getStatus());
}
final String content = response.getContentAsString();
@Nullable
final StatusResponse statusResponse = gson.fromJson(content, StatusResponse.class);
logger.trace("statusResponse: {}", statusResponse);
if (statusResponse.inputs() == null) {
logger.warn("List of inputs in response from IoTaWatt is null on device {}.", hostname);
}
if (statusResponse.outputs() == null) {
logger.warn("List of outputs in response from IoTaWatt is null on device {}.", hostname);
}
// noinspection ConstantConditions
return Optional.ofNullable(statusResponse);
} catch (InterruptedException e) {
throw new IoTaWattClientInterruptedException();
} catch (TimeoutException e) {
throw new IoTaWattClientCommunicationException();
} catch (URISyntaxException e) {
throw new IoTaWattClientConfigurationException(e);
} catch (ExecutionException e) {
logger.debug("Error on getting data from IoTaWatt {}", hostname);
throw new IoTaWattClientException();
}
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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();
}

View File

@ -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);
}

View File

@ -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());
}
}
}

View File

@ -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);
};
}
}

View File

@ -0,0 +1,53 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.iotawatt.internal.model;
import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import com.google.gson.annotations.SerializedName;
/**
* Status response of IoTaWatt.
*
* @author Peter Rosenberg - Initial contribution
*/
@NonNullByDefault
public record StatusResponse(@Nullable List<Input> inputs, @Nullable List<Output> outputs) {
/**
* Represents the inputs of IoTaWatt
*
* @param channel The channel ID
* @param vrms Current VRMS
* @param hz Current frequency
* @param phase Current phase
* @param watts Current watts
* @param pf Current power factor
*/
public record Input(int channel, @Nullable @SerializedName("Vrms") Float vrms,
@Nullable @SerializedName("Hz") Float hz, @Nullable Float phase,
@Nullable @SerializedName("Watts") Float watts, @Nullable @SerializedName("Pf") Float pf) {
}
/**
* Represents the outputs of IoTaWatt
*
* @param name Name of the output
* @param units Unit of the output
* @param value Current value of the output
*/
public record Output(String name, String units, Float value) {
}
}

View File

@ -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);
}

View File

@ -0,0 +1,147 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.iotawatt.internal.service;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.iotawatt.internal.client.IoTaWattClient;
import org.openhab.binding.iotawatt.internal.client.IoTaWattClientCommunicationException;
import org.openhab.binding.iotawatt.internal.client.IoTaWattClientConfigurationException;
import org.openhab.binding.iotawatt.internal.client.IoTaWattClientException;
import org.openhab.binding.iotawatt.internal.client.IoTaWattClientInterruptedException;
import org.openhab.binding.iotawatt.internal.model.IoTaWattChannelType;
import org.openhab.binding.iotawatt.internal.model.StatusResponse;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
/**
* Fetches data from IoTaWatt and updates the channels accordingly.
*
* @author Peter Rosenberg - Initial contribution
*/
@NonNullByDefault
public class FetchDataService {
static final String INPUT_CHANNEL_ID_PREFIX = "input_";
static final String OUTPUT_CHANNEL_ID_PREFIX = "output_";
private final DeviceHandlerCallback deviceHandlerCallback;
private @Nullable IoTaWattClient ioTaWattClient;
/**
* Creates a FetchDataService.
*
* @param deviceHandlerCallback The ThingHandler used for callbacks
*/
public FetchDataService(DeviceHandlerCallback deviceHandlerCallback) {
this.deviceHandlerCallback = deviceHandlerCallback;
}
/**
* Setter for the IoTaWattClient
*
* @param ioTaWattClient The IoTaWattClient to use
*/
public void setIoTaWattClient(IoTaWattClient ioTaWattClient) {
this.ioTaWattClient = ioTaWattClient;
}
/**
* Poll the device once without retry.
* Handles error cases and updates the Thing accordingly.
*/
public void pollDevice() {
Optional.ofNullable(ioTaWattClient).ifPresentOrElse(this::pollDevice, () -> deviceHandlerCallback
.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.HANDLER_INITIALIZING_ERROR));
}
private void pollDevice(IoTaWattClient client) {
try {
final Optional<StatusResponse> statusResponse = client.fetchStatus();
if (statusResponse.isPresent()) {
deviceHandlerCallback.updateStatus(ThingStatus.ONLINE);
updateChannels(statusResponse.get());
} else {
deviceHandlerCallback.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
}
} catch (IoTaWattClientInterruptedException e) {
deviceHandlerCallback.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NOT_YET_READY);
} catch (IoTaWattClientCommunicationException e) {
deviceHandlerCallback.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
} catch (IoTaWattClientConfigurationException e) {
deviceHandlerCallback.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
getErrorMessage(e));
} catch (IoTaWattClientException e) {
deviceHandlerCallback.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, getErrorMessage(e));
}
}
@Nullable
private String getErrorMessage(Throwable t) {
final Throwable cause = t.getCause();
return Objects.requireNonNullElse(cause, t).getMessage();
}
private void updateChannels(StatusResponse statusResponse) {
Optional.ofNullable(statusResponse.inputs()).ifPresent(this::updateInputs);
Optional.ofNullable(statusResponse.outputs()).ifPresent(this::updateOutputs);
}
private void updateInputs(List<StatusResponse.Input> inputs) {
for (final StatusResponse.Input input : inputs) {
final int channelNumber = input.channel();
createAndUpdateInputChannel(channelNumber, input.watts(), IoTaWattChannelType.WATTS);
createAndUpdateInputChannel(channelNumber, input.vrms(), IoTaWattChannelType.VOLTAGE);
createAndUpdateInputChannel(channelNumber, input.hz(), IoTaWattChannelType.FREQUENCY);
createAndUpdateInputChannel(channelNumber, input.pf(), IoTaWattChannelType.POWER_FACTOR);
createAndUpdateInputChannel(channelNumber, input.phase(), IoTaWattChannelType.PHASE);
}
}
private void updateOutputs(List<StatusResponse.Output> outputs) {
int index = 0;
for (final StatusResponse.Output output : outputs) {
final ChannelUID channelUID = new ChannelUID(deviceHandlerCallback.getThingUID(),
OUTPUT_CHANNEL_ID_PREFIX + toTwoDigits(index++) + "#" + output.name());
final Float value = output.value();
final IoTaWattChannelType ioTaWattChannelType = IoTaWattChannelType.fromOutputUnits(output.units());
deviceHandlerCallback.addChannelIfNotExists(channelUID, ioTaWattChannelType);
deviceHandlerCallback.updateState(channelUID, new QuantityType<>(value, ioTaWattChannelType.unit));
// TODO removed channels are not in array anymore
}
}
private void createAndUpdateInputChannel(int channelNumber, @Nullable Number value,
IoTaWattChannelType ioTaWattChannelType) {
final ChannelUID channelUID = getInputChannelUID(channelNumber, ioTaWattChannelType);
if (value != null) {
deviceHandlerCallback.addChannelIfNotExists(channelUID, ioTaWattChannelType);
deviceHandlerCallback.updateState(channelUID, new QuantityType<>(value, ioTaWattChannelType.unit));
// TODO removed channels are not in array anymore
}
}
private ChannelUID getInputChannelUID(int channelNumber, IoTaWattChannelType ioTaWattChannelType) {
return new ChannelUID(deviceHandlerCallback.getThingUID(),
INPUT_CHANNEL_ID_PREFIX + toTwoDigits(channelNumber) + "#" + ioTaWattChannelType.channelIdSuffix);
}
private String toTwoDigits(int value) {
return value < 10 ? ("0" + value) : String.valueOf(value);
}
}

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<addon:addon id="iotawatt" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:addon="https://openhab.org/schemas/addon/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/addon/v1.0.0 https://openhab.org/schemas/addon-1.0.0.xsd">
<type>binding</type>
<name>IoTaWatt Binding</name>
<description>This is the binding for IoTaWatt.</description>
<connection>local</connection>
</addon:addon>

View File

@ -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.

View File

@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="iotawatt"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<!-- Thing Type -->
<thing-type id="iotawatt">
<label>IoTaWatt Binding Thing</label>
<description>An IoTaWatt devices</description>
<config-description>
<parameter name="hostname" type="text" required="true">
<context>network-address</context>
<label>Hostname</label>
<description>Hostname or IP address of the device</description>
</parameter>
<parameter name="refreshInterval" type="integer" unit="s" min="1">
<label>Refresh Interval</label>
<description>Interval the device is polled in sec.</description>
<default>10</default>
<advanced>false</advanced>
</parameter>
<parameter name="requestTimeout" type="integer" unit="s" min="1">
<label>Request timeout</label>
<description>The request timeout to call the device in sec.</description>
<default>10</default>
<advanced>false</advanced>
</parameter>
<!-- run mvn i18n:generate-default-translations when updating the params -->
</config-description>
</thing-type>
<!-- Channel Types -->
<channel-type id="amps">
<item-type>Number:ElectricCurrent</item-type>
<label>Amps</label>
<description>The current Amps.</description>
<state pattern="%.2f %unit%" readOnly="true"/>
</channel-type>
<channel-type id="frequency">
<item-type>Number:Frequency</item-type>
<label>AC Frequency</label>
<description>The current AC frequency.</description>
<state pattern="%.2f %unit%" readOnly="true"/>
</channel-type>
<channel-type id="power-factor">
<item-type>Number:Dimensionless</item-type>
<label>Power Factor</label>
<description>The current power factor.</description>
<state pattern="%.2f" readOnly="true"/>
</channel-type>
<channel-type id="apparent-power">
<item-type>Number:Power</item-type>
<label>Apparent Power</label>
<description>The current apparent power.</description>
<state pattern="%.2f %unit%" readOnly="true"/>
</channel-type>
<channel-type id="reactive-power">
<item-type>Number:Power</item-type>
<label>Reactive Power</label>
<description>The current reactive power.</description>
<state pattern="%.2f %unit%" readOnly="true"/>
</channel-type>
<channel-type id="reactive-power-hour">
<item-type>Number:Energy</item-type>
<label>Reactive Power Hour</label>
<description>The current reactive power hour.</description>
<state pattern="%.2f %unit%" readOnly="true"/>
</channel-type>
<channel-type id="voltage">
<item-type>Number:ElectricPotential</item-type>
<label>Voltage</label>
<description>The current voltage.</description>
<category>Energy</category>
<tags>
<tag>Measurement</tag>
<tag>Voltage</tag>
</tags>
<state pattern="%.3f %unit%" readOnly="true">
</state>
</channel-type>
<channel-type id="watts">
<item-type>Number:Power</item-type>
<label>Power Consumption</label>
<description>The current power consumption.</description>
<category>Energy</category>
<tags>
<tag>Measurement</tag>
<tag>Power</tag>
</tags>
<state pattern="%.2f %unit%" readOnly="true">
</state>
</channel-type>
<channel-type id="phase">
<item-type>Number:Dimensionless</item-type>
<label>Phase</label>
<description>The current phase.</description>
<state pattern="%.2f" readOnly="true"/>
</channel-type>
</thing:thing-descriptions>

View File

@ -0,0 +1,216 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.iotawatt.internal.client;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.stream.Stream;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.client.api.Request;
import org.eclipse.jetty.http.HttpMethod;
import org.eclipse.jetty.http.HttpStatus;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.openhab.binding.iotawatt.internal.model.StatusResponse;
import com.google.gson.Gson;
/**
* @author Peter Rosenberg - Initial contribution
*/
@ExtendWith(MockitoExtension.class)
@NonNullByDefault
class IoTaWattClientTest {
private static final String DEVICE_STATUS_RESPONSE_FILE = "apiResponses/device-status-response.json";
@Mock
@NonNullByDefault({})
private HttpClient httpClient;
private final Gson gson = new Gson();
@Test
void fetchStatus_whenValidJson_returnObject() throws IOException, ExecutionException, InterruptedException,
TimeoutException, IoTaWattClientInterruptedException, IoTaWattClientCommunicationException,
IoTaWattClientConfigurationException, IoTaWattClientException {
// given
final IoTaWattClient client = new IoTaWattClient("hostname", 10, httpClient, gson);
Request request = mock(Request.class);
when(httpClient.newRequest(any(URI.class))).thenReturn(request);
when(request.method(any(HttpMethod.class))).thenReturn(request);
when(request.timeout(anyLong(), any(TimeUnit.class))).thenReturn(request);
ContentResponse contentResponse = mock(ContentResponse.class);
when(request.send()).thenReturn(contentResponse);
when(contentResponse.getStatus()).thenReturn(HttpStatus.OK_200);
when(contentResponse.getContentAsString()).thenReturn(readFile(DEVICE_STATUS_RESPONSE_FILE));
// when
Optional<StatusResponse> resultOptional = client.fetchStatus();
// then
// noinspection OptionalGetWithoutIsPresent
StatusResponse result = resultOptional.get();
assertThat(result.inputs().size(), is(2));
StatusResponse.Input input0 = result.inputs().get(0);
assertThat(input0.channel(), is(0));
assertThat(input0.vrms(), is(254.2972F));
assertThat(input0.hz(), is(50.02768F));
assertThat(input0.phase(), is(0.92F));
StatusResponse.Input input1 = result.inputs().get(1);
assertThat(input1.channel(), is(1));
assertThat(input1.watts(), is(1.42F));
assertThat(input1.phase(), is(2.2F));
}
@Test
void fetchStatus_whenWrongHost_throwException() {
// given
final IoTaWattClient client = new IoTaWattClient(" ", 10, httpClient, mock(Gson.class));
// when
assertThrows(IoTaWattClientConfigurationException.class, client::fetchStatus);
}
@Test
void fetchStatus_whenInputsAndOutputsEmpty_returnEmpty()
throws ExecutionException, InterruptedException, TimeoutException, IoTaWattClientInterruptedException,
IoTaWattClientCommunicationException, IoTaWattClientConfigurationException, IoTaWattClientException {
// given
final IoTaWattClient client = new IoTaWattClient("hostname", 10, httpClient, gson);
Request request = mock(Request.class);
when(httpClient.newRequest(any(URI.class))).thenReturn(request);
when(request.method(any(HttpMethod.class))).thenReturn(request);
when(request.timeout(anyLong(), any(TimeUnit.class))).thenReturn(request);
ContentResponse contentResponse = mock(ContentResponse.class);
when(request.send()).thenReturn(contentResponse);
when(contentResponse.getContentAsString()).thenReturn("{}");
when(contentResponse.getStatus()).thenReturn(HttpStatus.OK_200);
// when
Optional<StatusResponse> resultOptional = client.fetchStatus();
// then
// noinspection OptionalGetWithoutIsPresent
StatusResponse result = resultOptional.get();
assertNull(result.inputs());
assertNull(result.outputs());
}
@Test
void fetchStatus_whenNot200Response_throwsException()
throws ExecutionException, InterruptedException, TimeoutException {
// given
final IoTaWattClient client = new IoTaWattClient("hostname", 10, httpClient, gson);
Request request = mock(Request.class);
when(httpClient.newRequest(any(URI.class))).thenReturn(request);
when(request.method(any(HttpMethod.class))).thenReturn(request);
when(request.timeout(anyLong(), any(TimeUnit.class))).thenReturn(request);
ContentResponse contentResponse = mock(ContentResponse.class);
when(request.send()).thenReturn(contentResponse);
when(contentResponse.getStatus()).thenReturn(HttpStatus.BAD_REQUEST_400);
// when/then
assertThrows(IoTaWattClientCommunicationException.class, client::fetchStatus);
}
@ParameterizedTest
@MethodSource("provideParamsForThrowCases")
void fetchStatus_whenExceptions_throwsCustomException(Class<Throwable> thrownException,
Class<Throwable> expectedException) throws ExecutionException, InterruptedException, TimeoutException {
// given
final IoTaWattClient client = new IoTaWattClient("hostname", 10, httpClient, gson);
Request request = mock(Request.class);
when(httpClient.newRequest(any(URI.class))).thenReturn(request);
when(request.method(any(HttpMethod.class))).thenReturn(request);
when(request.timeout(anyLong(), any(TimeUnit.class))).thenReturn(request);
when(request.send()).thenThrow(thrownException);
// when/then
assertThrows(expectedException, client::fetchStatus);
}
@Test
void start_whenSuccess_noException() {
// given
final IoTaWattClient client = new IoTaWattClient("hostname", 10, httpClient, gson);
// when
client.start();
// then
// doesn't throw an exception
}
@Test
void start_whenError_throwException() throws Exception {
// given
final IoTaWattClient client = new IoTaWattClient("hostname", 10, httpClient, gson);
doThrow(Exception.class).when(httpClient).start();
// when/then
assertThrows(IllegalStateException.class, client::start);
}
@Test
void stop_whenSuccess_noException() {
// given
final IoTaWattClient client = new IoTaWattClient("hostname", 10, httpClient, gson);
// when
client.stop();
// then
// doesn't throw an exception
}
@Test
void stop_whenError_noException() throws Exception {
// given
final IoTaWattClient client = new IoTaWattClient("hostname", 10, httpClient, gson);
doThrow(Exception.class).when(httpClient).stop();
// when
client.stop();
// then
// doesn't throw an exception
}
private static Stream<Arguments> provideParamsForThrowCases() {
return Stream.of(Arguments.of(InterruptedException.class, IoTaWattClientInterruptedException.class),
Arguments.of(TimeoutException.class, IoTaWattClientCommunicationException.class),
Arguments.of(ExecutionException.class, IoTaWattClientException.class));
}
private String readFile(String filename) throws IOException {
final Path workingDir = Path.of("", "src/test/resources");
final Path file = workingDir.resolve(filename);
return Files.readString(file);
}
}

View File

@ -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));
}
}

View File

@ -0,0 +1,251 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.iotawatt.internal.service;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.openhab.binding.iotawatt.internal.service.FetchDataService.INPUT_CHANNEL_ID_PREFIX;
import static org.openhab.binding.iotawatt.internal.service.FetchDataService.OUTPUT_CHANNEL_ID_PREFIX;
import java.util.List;
import java.util.Optional;
import java.util.stream.Stream;
import javax.measure.Unit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.openhab.binding.iotawatt.internal.IoTaWattBindingConstants;
import org.openhab.binding.iotawatt.internal.client.IoTaWattClient;
import org.openhab.binding.iotawatt.internal.client.IoTaWattClientCommunicationException;
import org.openhab.binding.iotawatt.internal.client.IoTaWattClientConfigurationException;
import org.openhab.binding.iotawatt.internal.client.IoTaWattClientException;
import org.openhab.binding.iotawatt.internal.client.IoTaWattClientInterruptedException;
import org.openhab.binding.iotawatt.internal.model.StatusResponse;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.unit.Units;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.ThingUID;
import org.openhab.core.types.State;
/**
* @author Peter Rosenberg - Initial contribution
*/
@ExtendWith(MockitoExtension.class)
@NonNullByDefault
class FetchDataServiceTest {
@Mock
@NonNullByDefault({})
private DeviceHandlerCallback deviceHandlerCallback;
@Mock
@NonNullByDefault({})
private IoTaWattClient ioTaWattClient;
@InjectMocks
@NonNullByDefault({})
private FetchDataService service;
private final ThingUID thingUID = new ThingUID(IoTaWattBindingConstants.BINDING_ID, "d231dea2e4");
@Test
void pollDevice_whenAllSupportedInputTypes_updateAllChannels() throws IoTaWattClientInterruptedException,
IoTaWattClientCommunicationException, IoTaWattClientConfigurationException, IoTaWattClientException {
// given
service.setIoTaWattClient(ioTaWattClient);
final Float voltageRms = 259.1f;
final Float hertz = 50.1f;
final Float phase0 = 0.1f;
final Float wattsValue = 1.1f;
final Float phase1 = 0.2f;
final Float powerFactor = 0.3f;
when(deviceHandlerCallback.getThingUID()).thenReturn(thingUID);
final List<StatusResponse.Input> inputs = List.of(
new StatusResponse.Input(0, voltageRms, hertz, phase0, null, null),
new StatusResponse.Input(1, null, null, phase1, wattsValue, powerFactor));
final StatusResponse statusResponse = new StatusResponse(inputs, List.of());
when(ioTaWattClient.fetchStatus()).thenReturn(Optional.of(statusResponse));
// when
service.pollDevice();
// then
verify(deviceHandlerCallback).updateStatus(ThingStatus.ONLINE);
verify(deviceHandlerCallback).updateState(createInputChannelUID("00", "voltage"),
createState(voltageRms, Units.VOLT));
verify(deviceHandlerCallback).updateState(createInputChannelUID("00", "frequency"),
createState(hertz, Units.HERTZ));
verify(deviceHandlerCallback).updateState(createInputChannelUID("00", "phase"), createState(phase0, Units.ONE));
verify(deviceHandlerCallback).updateState(createInputChannelUID("01", "watts"),
createState(wattsValue, Units.WATT));
verify(deviceHandlerCallback).updateState(createInputChannelUID("01", "phase"), createState(phase1, Units.ONE));
verify(deviceHandlerCallback).updateState(createInputChannelUID("01", "power-factor"),
createState(powerFactor, Units.ONE));
}
@Test
void pollDevice_whenAllSupportedOutputTypes_updateAllChannels() throws IoTaWattClientInterruptedException,
IoTaWattClientCommunicationException, IoTaWattClientConfigurationException, IoTaWattClientException {
// given
service.setIoTaWattClient(ioTaWattClient);
when(deviceHandlerCallback.getThingUID()).thenReturn(thingUID);
final List<StatusResponse.Output> outputs = List.of(new StatusResponse.Output("name_amps", "Amps", 1.01f),
new StatusResponse.Output("name_hz", "Hz", 1.02f), new StatusResponse.Output("name_pf", "PF", 1.03f),
new StatusResponse.Output("name_va", "VA", 1.04f), new StatusResponse.Output("name_var", "VAR", 1.05f),
new StatusResponse.Output("name_varh", "VARh", 1.06f),
new StatusResponse.Output("name_volts", "Volts", 1.07f),
new StatusResponse.Output("name_watts", "Watts", 1.08f));
final StatusResponse statusResponse = new StatusResponse(List.of(), outputs);
when(ioTaWattClient.fetchStatus()).thenReturn(Optional.of(statusResponse));
// when
service.pollDevice();
// then
verify(deviceHandlerCallback).updateStatus(ThingStatus.ONLINE);
verify(deviceHandlerCallback).updateState(createOutputChannelUID("00", "name_amps"),
createState(1.01f, Units.AMPERE));
verify(deviceHandlerCallback).updateState(createOutputChannelUID("01", "name_hz"),
createState(1.02f, Units.HERTZ));
verify(deviceHandlerCallback).updateState(createOutputChannelUID("02", "name_pf"),
createState(1.03f, Units.ONE));
verify(deviceHandlerCallback).updateState(createOutputChannelUID("03", "name_va"),
createState(1.04f, Units.VOLT_AMPERE));
verify(deviceHandlerCallback).updateState(createOutputChannelUID("04", "name_var"),
createState(1.05f, Units.VAR));
verify(deviceHandlerCallback).updateState(createOutputChannelUID("05", "name_varh"),
createState(1.06f, Units.VAR_HOUR));
verify(deviceHandlerCallback).updateState(createOutputChannelUID("06", "name_volts"),
createState(1.07f, Units.VOLT));
verify(deviceHandlerCallback).updateState(createOutputChannelUID("07", "name_watts"),
createState(1.08f, Units.WATT));
}
@Test
void pollDevice_whenResponseWithNoChannels_updateStatusToOnlineAndDoNotUpdateChannels()
throws IoTaWattClientInterruptedException, IoTaWattClientCommunicationException,
IoTaWattClientConfigurationException, IoTaWattClientException {
// given
service.setIoTaWattClient(ioTaWattClient);
when(ioTaWattClient.fetchStatus()).thenReturn(Optional.empty());
// when
service.pollDevice();
// then
verify(deviceHandlerCallback).updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
verify(deviceHandlerCallback, never()).updateState(any(), any());
}
@Test
void pollDevice_whenExceptionWithCase_useCauseMessage() throws IoTaWattClientInterruptedException,
IoTaWattClientCommunicationException, IoTaWattClientConfigurationException, IoTaWattClientException {
// given
final String exceptionMessage = "test message";
service.setIoTaWattClient(ioTaWattClient);
final Throwable exception = new IoTaWattClientConfigurationException(new Throwable(exceptionMessage));
when(ioTaWattClient.fetchStatus()).thenThrow(exception);
// when
service.pollDevice();
// then
verify(deviceHandlerCallback).updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
exceptionMessage);
verify(deviceHandlerCallback, never()).updateState(any(), any());
}
@Test
void pollDevice_whenEmptyResponse_updateStatusToOffline() {
// given
// do not set service.setIoTaWattClient(ioTaWattClient);
// when
service.pollDevice();
// then
verify(deviceHandlerCallback).updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.HANDLER_INITIALIZING_ERROR);
verify(deviceHandlerCallback, never()).updateState(any(), any());
}
@Test
void pollDevice_whenNotInitialised_fail() throws IoTaWattClientInterruptedException,
IoTaWattClientCommunicationException, IoTaWattClientConfigurationException, IoTaWattClientException {
// given
service.setIoTaWattClient(ioTaWattClient);
final StatusResponse statusResponse = new StatusResponse(List.of(), List.of());
when(ioTaWattClient.fetchStatus()).thenReturn(Optional.of(statusResponse));
// when
service.pollDevice();
// then
verify(deviceHandlerCallback).updateStatus(ThingStatus.ONLINE);
verify(deviceHandlerCallback, never()).updateState(any(), any());
}
@ParameterizedTest
@MethodSource("provideParamsForThrowCases")
void pollDevice_whenApiRequestThrowsInterruptedException_updateStatusAccordingly(Class<Throwable> throwableClass,
ThingStatusDetail thingStatusDetail, boolean withErrorMessage) throws IoTaWattClientInterruptedException,
IoTaWattClientCommunicationException, IoTaWattClientConfigurationException, IoTaWattClientException {
// given
final String errorMessage = "Error message";
service.setIoTaWattClient(ioTaWattClient);
final Throwable thrownThrowable = mock(throwableClass);
if (withErrorMessage) {
when(thrownThrowable.getMessage()).thenReturn(errorMessage);
}
when(ioTaWattClient.fetchStatus()).thenThrow(thrownThrowable);
// when
service.pollDevice();
// then
if (withErrorMessage) {
verify(deviceHandlerCallback).updateStatus(ThingStatus.OFFLINE, thingStatusDetail, errorMessage);
} else {
verify(deviceHandlerCallback).updateStatus(ThingStatus.OFFLINE, thingStatusDetail);
}
verify(deviceHandlerCallback, never()).updateState(any(), any());
}
private static Stream<Arguments> provideParamsForThrowCases() {
return Stream.of(Arguments.of(IoTaWattClientInterruptedException.class, ThingStatusDetail.NOT_YET_READY, false),
Arguments.of(IoTaWattClientCommunicationException.class, ThingStatusDetail.COMMUNICATION_ERROR, false),
Arguments.of(IoTaWattClientConfigurationException.class, ThingStatusDetail.CONFIGURATION_ERROR, true),
Arguments.of(IoTaWattClientException.class, ThingStatusDetail.NONE, true));
}
private ChannelUID createInputChannelUID(String channelNumberStr, String channelName) {
return new ChannelUID(thingUID, INPUT_CHANNEL_ID_PREFIX + channelNumberStr + "#" + channelName);
}
private ChannelUID createOutputChannelUID(String channelNumber, String channelName) {
return new ChannelUID(thingUID, OUTPUT_CHANNEL_ID_PREFIX + channelNumber + "#" + channelName);
}
private State createState(Float value, Unit<?> unit) {
return new QuantityType<>(value, unit);
}
}

View File

@ -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"
}
}

View File

@ -197,6 +197,7 @@
<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>