[myuplink] Initial contribution (#17451)

* myuplink skeleton

Signed-off-by: Alexander Friese <af944580@googlemail.com>
Signed-off-by: Ciprian Pascu <contact@ciprianpascu.ro>
This commit is contained in:
Alexander Friese 2024-12-03 12:33:08 +01:00 committed by Ciprian Pascu
parent 546b58e0da
commit dcc8cb85cb
49 changed files with 4369 additions and 0 deletions

View File

@ -246,6 +246,7 @@
/bundles/org.openhab.binding.mybmw/ @ntruchsess @mherwege @martingrassl
/bundles/org.openhab.binding.mynice/ @clinique
/bundles/org.openhab.binding.mystrom/ @pail23
/bundles/org.openhab.binding.myuplink/ @alexf2015
/bundles/org.openhab.binding.nanoleaf/ @stefan-hoehn
/bundles/org.openhab.binding.neato/ @jjlauterbach
/bundles/org.openhab.binding.neeo/ @morph166955

View File

@ -1236,6 +1236,11 @@
<artifactId>org.openhab.binding.mystrom</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.myuplink</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.nanoleaf</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,78 @@
# myUplink Binding
The myUplink binding is used to get "live data" from from Nibe heat pumps without plugging any custom devices into your heat pump.
This avoids the risk of losing your warranty.
Instead data is retrieved from myUplink.
The myUplink API is the successor of the Nibe Uplink API.
This binding should in general be compatible with all heat pump models that support myUplink.
Read or write access is supported by all channels as exposed by the API.
Write access might only be available with a paid subscription for myUplink.
You will need to create credentials at <https://dev.myuplink.com/apps> in order to use this binding.
## Supported Things
This binding provides two thing types:
| Thing/Bridge | Thing Type | Description |
|---------------------|---------------------|-------------------------------------------------------------------|
| bridge | account | cloud connection to a myUplink user account |
| thing | generic-device | the physical heatpump which is connected to myUplink |
## Discovery
When the `account` bridge is setup, the binding will discover all heatpumps within that account and also detect the specific channels supported by the model.
## Bridge Configuration
The following configuration parameters are available for the bridge:
| Configuration Parameter | Required | Description |
|-------------------------|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| clientId | yes | The clientId to login at myUplink cloud service. This is some kind of UUID. Visit <https://dev.myuplink.com/apps> to generate login credentials. |
| clientSecret | yes | The secret which belongs to the clientId. |
| dataPollingInterval | no | Interval (seconds) in which live data values are retrieved from the Easee Cloud API. (default = 60) |
## Thing Configuration
It is recommended to use auto discovery which does not require further configuration.
If manual configuration is preferred you need to specify configuration as below.
| Configuration Parameter | Required | Description |
|-------------------------|----------|------------------------------------------------------------------------------------------------------------------------|
| deviceId | yes | The id of the heatpump that will be represented by this thing. Can be retrieved via API call or autodiscovery. |
| systemId | no | The systemId of the heatpump. Only needed for "SmartHomeMode". Can be retrieved via API call or autodiscovery. |
## Channels
The binding only supports channels which are explicitely exposed by the myUplink API.
Depending on your model and additional hardware the channels might be different.
Thus no list is provided here.
## Full Example
The configuration below is an example which could easily be adopted to your actual model.
Thing configuration (account and generic-device) is the same for all models.
Item configuration depends on your specific model and thus channels will have different IDs and/or channels might not exist for all models.
### `demo.things` Example
```java
Bridge myuplink:account:myAccount "myUplink" [
clientId="c7c2f9a4-b960-448f-b00d-b8f30aff3324",
clientSecret="471147114711ABCDEF133713371337AB",
dataPollingInterval=55
] {
Thing generic-device vvm320 "VVM320" [ deviceId="id taken from automatic discovery", systemId="id taken from automatic discovery" ]
}
```
### `demo.items` Example
```java
Number NIBE_ADD_STATUS "Status ZH [%s]" { channel="myuplink:generic-device:myAccount:vvm320:49993" }
Number NIBE_COMP_STATUS "Status Compr. [%s]" { channel="myuplink:generic-device:myAccount:vvm320:44064" }
Number:Temperature NIBE_SUPPLY "Supply line" { unit="°C", channel="myuplink:generic-device:myAccount:vvm320:40008" }
Number:Temperature NIBE_RETURN "Return line" { unit="°C", channel="myuplink:generic-device:myAccount:vvm320:40012" }
Number:Energy NIBE_HM_HEAT "HM heating" { unit="kWh", channel="myuplink:generic-device:myAccount:vvm320:44308" }
Number:Energy NIBE_HM_HW "HM hot water" { unit="kWh", channel="myuplink:generic-device:myAccount:vvm320:44306" }
```

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
<version>4.3.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.binding.myuplink</artifactId>
<name>openHAB Add-ons :: Bundles :: myUplink Binding</name>
</project>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<features name="org.openhab.binding.myuplink-${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-myuplink" description="myUplink Binding" version="${project.version}">
<feature>openhab-runtime-base</feature>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.myuplink/${project.version}</bundle>
</feature>
</features>

View File

@ -0,0 +1,59 @@
/**
* 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.myuplink.internal;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicReference;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* trait class which contains useful helper methods. Thus, the interface can be implemented and methods are available
* within the class.
*
* @author Alexander Friese - initial contribution
*/
@NonNullByDefault
public interface AtomicReferenceTrait {
/**
* this should usually not called directly. use updateJobReference or cancelJobReference instead
*
* @param job job to cancel.
*/
default void cancelJob(@Nullable Future<?> job) {
if (job != null) {
job.cancel(true);
}
}
/**
* updates a job reference with a new job. the old job will be cancelled if there is one.
*
* @param jobReference reference to be updated
* @param newJob job to be assigned
*/
default void updateJobReference(AtomicReference<@Nullable Future<?>> jobReference, Future<?> newJob) {
cancelJob(jobReference.getAndSet(newJob));
}
/**
* updates a job reference to null and cancels any existing job which might be assigned to the reference.
*
* @param jobReference to be updated to null.
*/
default void cancelJobReference(AtomicReference<@Nullable Future<?>> jobReference) {
cancelJob(jobReference.getAndSet(null));
}
}

View File

@ -0,0 +1,181 @@
/**
* 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.myuplink.internal;
import java.time.Instant;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.thing.ThingTypeUID;
/**
* The {@link MyUplinkBindingConstants} class defines common constants, which are
* used across the whole binding.
*
* @author Alexander Friese - Initial contribution
*/
@NonNullByDefault
public class MyUplinkBindingConstants {
public static final String BINDING_ID = "myuplink";
// List of main device types
public static final String DEVICE_ACCOUNT = "account";
public static final String DEVICE_GENERIC_DEVICE = "generic-device";
// List of all Thing Type UIDs
public static final ThingTypeUID THING_TYPE_ACCOUNT = new ThingTypeUID(BINDING_ID, DEVICE_ACCOUNT);
public static final ThingTypeUID THING_TYPE_GENERIC_DEVICE = new ThingTypeUID(BINDING_ID, DEVICE_GENERIC_DEVICE);
public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_ACCOUNT,
THING_TYPE_GENERIC_DEVICE);
// Channel types
public static final String CHANNEL_TYPE_UNIT_NONE = "NO_UNIT";
public static final String CHANNEL_TYPE_PREFIX_RW = "rw";
public static final String CHANNEL_TYPE_ENUM_PRFIX = "type-enum-";
public static final String CHANNEL_TYPE_NUMERIC_PRFIX = "type-numeric-";
public static final String CHANNEL_TYPE_DEFAULT_DATATYPE = "Number";
public static final String CHANNEL_TYPE_ENERGY = "type-energy";
public static final String CHANNEL_TYPE_ENERGY_UNIT = "kWh";
public static final String CHANNEL_TYPE_PRESSURE = "type-pressure";
public static final String CHANNEL_TYPE_PRESSURE_UNIT = "bar";
public static final String CHANNEL_TYPE_PERCENT = "type-percent";
public static final String CHANNEL_TYPE_PERCENT_UNIT = "%";
public static final String CHANNEL_TYPE_TEMPERATURE = "type-temperature";
public static final String CHANNEL_TYPE_TEMPERATURE_UNIT = "°C";
public static final String CHANNEL_TYPE_FREQUENCY = "type-frequency";
public static final String CHANNEL_TYPE_FREQUENCY_UNIT = "Hz";
public static final String CHANNEL_TYPE_FLOW = "type-flow";
public static final String CHANNEL_TYPE_FLOW_UNIT = "l/m";
public static final String CHANNEL_TYPE_ELECTRIC_CURRENT = "type-electric-current";
public static final String CHANNEL_TYPE_ELECTRIC_CURRENT_UNIT = "A";
public static final String CHANNEL_TYPE_TIME = "type-time";
public static final String CHANNEL_TYPE_TIME_UNIT = "h";
public static final String CHANNEL_TYPE_INTEGER = "type-number-integer";
public static final String CHANNEL_TYPE_DOUBLE = "type-number-double";
public static final String CHANNEL_TYPE_ON_OFF = "type-on-off";
public static final String CHANNEL_TYPE_RW_SWITCH = "rwtype-switch";
public static final String CHANNEL_TYPE_RW_COMMAND = "rwtype-command";
public static final String CHANNEL_TYPE_RW_MODE = "rwtype-mode";
public static final String CHANNEL_ID_COMMAND = "command";
public static final String CHANNEL_ID_SMART_HOME_MODE = "smart-home-mode";
// JSON Keys
public static final String JSON_KEY_ROOT_DATA = "data";
public static final String JSON_KEY_CHANNEL_STR_VAL = "strVal";
public static final String JSON_KEY_CHANNEL_VALUE = "value";
public static final String JSON_KEY_CHANNEL_WRITABLE = "writable";
public static final String JSON_KEY_CHANNEL_ENUM_VALUES = "enumValues";
public static final String JSON_KEY_CHANNEL_ID = "parameterId";
public static final String JSON_KEY_CHANNEL_LABEL = "parameterName";
public static final String JSON_KEY_CHANNEL_UNIT = "parameterUnit";
public static final String JSON_KEY_CHANNEL_SCALE = "scaleValue";
public static final String JSON_KEY_CHANNEL_MIN = "minValue";
public static final String JSON_KEY_CHANNEL_MAX = "maxValue";
public static final String JSON_KEY_CHANNEL_STEP = "stepValue";
public static final String JSON_KEY_SYSTEMS = "systems";
public static final String JSON_KEY_SYSTEM_ID = "systemId";
public static final String JSON_KEY_DEVICES = "devices";
public static final String JSON_KEY_GENERIC_ID = "id";
public static final String JSON_KEY_PRODUCT = "product";
public static final String JSON_KEY_SERIAL = "serialNumber";
public static final String JSON_KEY_NAME = "name";
public static final String JSON_KEY_CURRENT_FW_VERSION = "currentFwVersion";
public static final String JSON_KEY_CONNECTION_STATE = "connectionState";
public static final String JSON_KEY_ERROR = "error";
public static final String JSON_KEY_SMART_HOME_MODE = "smartHomeMode";
public static final String JSON_KEY_AUTH_ACCESS_TOKEN = "access_token";
public static final String JSON_KEY_AUTH_EXPIRES_IN = "expires_in";
public static final String JSON_ENUM_KEY_TEXT = "text";
public static final String JSON_ENUM_ORD_0 = "0";
public static final String JSON_ENUM_ORD_1 = "1";
public static final String JSON_ENUM_ORD_4 = "4";
public static final String JSON_ENUM_ORD_6 = "6";
public static final String JSON_ENUM_ORD_10 = "10";
public static final String JSON_ENUM_ORD_20 = "20";
public static final String JSON_ENUM_ORD_30 = "30";
public static final String JSON_ENUM_ORD_40 = "40";
public static final String JSON_ENUM_ORD_60 = "60";
public static final String JSON_ENUM_ORD_100 = "100";
public static final String JSON_ENUM_VAL_OFF = "off";
public static final String JSON_ENUM_VAL_ON = "on";
public static final String JSON_ENUM_VAL_HOT_WATER = "hot water";
public static final String JSON_ENUM_VAL_HEATING = "heating";
public static final String JSON_ENUM_VAL_POOL = "pool";
public static final String JSON_ENUM_VAL_STARTS = "starts";
public static final String JSON_ENUM_VAL_RUNS = "runs";
public static final String JSON_ENUM_VAL_ALARM = "alarm";
public static final String JSON_ENUM_VAL_BLOCKED = "blocked";
public static final String JSON_ENUM_VAL_ACTIVE = "active";
public static final String JSON_VAL_CONNECTION_CONNECTED = "Connected";
public static final String JSON_VAL_DECIMAL_SEPARATOR = ".";
// web request constants
public static final long WEB_REQUEST_INITIAL_DELAY = 10;
public static final long WEB_REQUEST_INTERVAL = 5;
public static final int WEB_REQUEST_QUEUE_MAX_SIZE = 20;
public static final int WEB_REQUEST_TOKEN_EXPIRY_BUFFER_MINUTES = 5;
public static final int WEB_REQUEST_TOKEN_MAX_AGE_MINUTES = 45;
public static final String WEB_REQUEST_PARAM_PAGE_KEY = "page";
public static final String WEB_REQUEST_PARAM_PAGE_SIZE_KEY = "itemsPerPage";
public static final String WEB_REQUEST_PATCH_CONTENT_TYPE = "application/json-patch+json";
public static final int WEB_REQUEST_PARAM_PAGE_SIZE_VALUE = 100;
public static final String WEB_REQUEST_BEARER_TOKEN_PREFIX = "Bearer ";
public static final String LOGIN_BASIC_AUTH_PREFIX = "Basic ";
public static final String LOGIN_FIELD_SCOPE_KEY = "scope";
public static final String LOGIN_FIELD_SCOPE_VALUE = "READSYSTEM WRITESYSTEM";
public static final String LOGIN_FIELD_GRANT_TYPE_KEY = "grant_type";
public static final String LOGIN_FIELD_GRANT_TYPE_VALUE = "client_credentials";
// URLs
private static final String API_BASE_URL = "https://api.myuplink.com";
public static final String LOGIN_URL = API_BASE_URL + "/oauth/token";
public static final String GET_SYSTEMS_URL = API_BASE_URL + "/v2/systems/me";
public static final String GET_SMART_HOME_MODE_URL = API_BASE_URL + "/v2/systems/{systemId}/smart-home-mode";
public static final String SET_SMART_HOME_MODE_URL = GET_SMART_HOME_MODE_URL;
public static final String GET_DEVICE_POINTS = API_BASE_URL + "/v2/devices/{deviceId}/points";
public static final String SET_DEVICE_POINTS = GET_DEVICE_POINTS;
// Status Keys
public static final String STATUS_TOKEN_VALIDATED = "@text/status.token.validated";
public static final String STATUS_WAITING_FOR_BRIDGE = "@text/status.waiting.for.bridge";
public static final String STATUS_WAITING_FOR_LOGIN = "@text/status.waiting.for.login";
public static final String STATUS_NO_VALID_DATA = "@text/status.no.valid.data";
public static final String STATUS_NO_CONNECTION = "@text/status.no.connection";
public static final String STATUS_DEVICE_NOT_FOUND = "@text/status.device.not.found";
public static final String STATUS_CONFIG_ERROR_NO_CLIENT_ID = "@text/status.config.error.no.client.id";
public static final String STATUS_CONFIG_ERROR_NO_CLIENT_SECRET = "@text/status.config.error.no.client.secret";
// other
public static final long POLLING_INITIAL_DELAY = 5;
public static final String GENERIC_NO_VAL = "---";
public static final String EMPTY = "";
public static final String THING_CONFIG_ID = "deviceId";
public static final String THING_CONFIG_SYSTEM_ID = "systemId";
public static final String THING_CONFIG_SERIAL = "serial";
public static final String THING_CONFIG_CURRENT_FW_VERSION = "currentFwVersion";
public static final Instant OUTDATED_DATE = Instant.EPOCH;
public static final String PARAMETER_NAME_WRITE_COMMAND = "writeCommand";
public static final String PARAMETER_NAME_VALIDATION_REGEXP = "validationExpression";
public static final String DEFAULT_VALIDATION_EXPRESSION = "[0-9]+";
}

View File

@ -0,0 +1,81 @@
/**
* 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.myuplink.internal;
import static org.openhab.binding.myuplink.internal.MyUplinkBindingConstants.*;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.openhab.binding.myuplink.internal.handler.MyUplinkAccountHandler;
import org.openhab.binding.myuplink.internal.handler.MyUplinkGenericDeviceHandler;
import org.openhab.binding.myuplink.internal.provider.ChannelFactory;
import org.openhab.core.io.net.http.HttpClientFactory;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
import org.openhab.core.thing.binding.ThingHandler;
import org.openhab.core.thing.binding.ThingHandlerFactory;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link myUplinkHandlerFactory} is responsible for creating things and thing
* handlers.
*
* @author Alexander Friese - Initial contribution
*/
@NonNullByDefault
@Component(configurationPid = "binding.myuplink", service = ThingHandlerFactory.class)
public class MyUplinkHandlerFactory extends BaseThingHandlerFactory {
private final Logger logger = LoggerFactory.getLogger(MyUplinkHandlerFactory.class);
/**
* the shared http client
*/
private final HttpClient httpClient;
private final ChannelFactory channelFactory;
@Activate
public MyUplinkHandlerFactory(final @Reference HttpClientFactory httpClientFactory,
final @Reference ChannelFactory channelFactory) {
this.httpClient = httpClientFactory.getCommonHttpClient();
this.channelFactory = channelFactory;
}
@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_ACCOUNT.equals(thingTypeUID)) {
return new MyUplinkAccountHandler((Bridge) thing, httpClient);
} else if (THING_TYPE_GENERIC_DEVICE.equals(thingTypeUID)) {
return new MyUplinkGenericDeviceHandler(thing, channelFactory);
} else {
logger.warn("Unsupported Thing-Type: {}", thingTypeUID.getAsString());
}
return null;
}
}

View File

@ -0,0 +1,237 @@
/**
* 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.myuplink.internal;
import static org.openhab.binding.myuplink.internal.MyUplinkBindingConstants.*;
import java.math.BigDecimal;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.myuplink.internal.model.ConfigurationException;
import org.openhab.core.thing.Channel;
import org.openhab.core.thing.type.ChannelTypeUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonNull;
import com.google.gson.JsonObject;
import com.google.gson.JsonPrimitive;
/**
* some helper methods.
*
* @author Alexander Friese - initial contribution
*/
@NonNullByDefault
public final class Utils {
private static final Logger LOGGER = LoggerFactory.getLogger(Utils.class);
/**
* only static methods no instance needed
*/
private Utils() {
}
/**
* parses a date string in api source format to ZonedDateTime which is used by openHAB.
*
* @param date
* @return
*/
public static ZonedDateTime parseDate(String date) throws DateTimeParseException {
DateTimeFormatter formatter;
if (date.length() == 24) {
formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSX");
} else {
formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssX");
}
LOGGER.trace("parsing: {}", date);
return ZonedDateTime.parse(date, formatter);
}
/**
* get element as JsonObject.
*
* @param jsonObject
* @param key
* @return
*/
public static @Nullable JsonObject getAsJsonObject(@Nullable JsonObject jsonObject, String key) {
JsonElement element = jsonObject == null ? null : jsonObject.get(key);
return (element instanceof JsonObject) ? element.getAsJsonObject() : null;
}
/**
* get element as JsonArray.
*
* @param jsonObject
* @param key
* @return
*/
public static JsonArray getAsJsonArray(@Nullable JsonObject jsonObject, String key) {
JsonElement element = jsonObject == null ? null : jsonObject.get(key);
return (element instanceof JsonArray) ? element.getAsJsonArray() : new JsonArray();
}
/**
* get element as String.
*
* @param jsonObject
* @param key
* @return
*/
public static @Nullable String getAsString(@Nullable JsonObject jsonObject, String key) {
JsonElement element = jsonObject == null ? null : jsonObject.get(key);
String text = null;
if (element != null) {
if (element instanceof JsonPrimitive) {
text = element.getAsString();
} else if (element instanceof JsonObject || element instanceof JsonArray) {
text = element.toString();
}
}
return text;
}
/**
* null safe version of getAsString with default value.
*
* @param jsonObject
* @param key
* @param defaultVal
* @return
*/
public static String getAsString(@Nullable JsonObject jsonObject, String key, String defaultVal) {
String text = getAsString(jsonObject, key);
return text == null ? defaultVal : text;
}
/**
* get element as int.
*
* @param jsonObject
* @param key
* @return
*/
public static int getAsInt(@Nullable JsonObject jsonObject, String key) {
JsonElement element = jsonObject == null ? null : jsonObject.get(key);
return (element instanceof JsonPrimitive) ? element.getAsInt() : 0;
}
/**
* get element as BigDecimal.
*
* @param jsonObject
* @param key
* @return
*/
public static @Nullable BigDecimal getAsBigDecimal(@Nullable JsonObject jsonObject, String key) {
JsonElement element = jsonObject == null ? null : jsonObject.get(key);
return (element == null || element instanceof JsonNull) ? null : element.getAsBigDecimal();
}
/**
* get element as boolean.
*
* @param jsonObject
* @param key
* @return
*/
public static @Nullable Boolean getAsBool(@Nullable JsonObject jsonObject, String key) {
JsonElement json = jsonObject == null ? null : jsonObject.get(key);
return (json == null || json instanceof JsonNull) ? null : json.getAsBoolean();
}
/**
* null safe version of getAsBool with default value.
*
* @param jsonObject
* @param key
* @return
*/
public static boolean getAsBool(@Nullable JsonObject jsonObject, String key, Boolean defaultValue) {
Boolean result = getAsBool(jsonObject, key);
return result == null ? defaultValue : result;
}
/**
* retrieves typeID of a channel.
*
* @param channel
* @return typeID or empty string if typeUID is null.
*/
public static String getChannelTypeId(Channel channel) {
ChannelTypeUID typeUID = channel.getChannelTypeUID();
if (typeUID == null) {
return "";
}
return typeUID.getId();
}
/**
* retrieves the validation expression which is assigned to this channel, fallback to a public static, if no
* validation
* is
* defined.
*
* @param channel
* @return the validation expression
*/
public static String getValidationExpression(Channel channel) {
String expr = getPropertyOrParameter(channel, PARAMETER_NAME_VALIDATION_REGEXP);
if (expr == null) {
throw new ConfigurationException(
"channel (" + channel.getUID().getId() + ") does not have a validation expression configured");
}
return expr;
}
/**
* internal utiliy method which returns a property (if found) or a config parameter (if found) otherwise null
*
* @param channel
* @param name
* @return
*/
public static @Nullable String getPropertyOrParameter(Channel channel, String name) {
String value = channel.getProperties().get(name);
// also eclipse says this cannot be null, it definitely can!
if (value == null || value.isEmpty()) {
Object obj = channel.getConfiguration().get(name);
value = obj == null ? null : obj.toString();
}
return value;
}
/**
* converts units received from myUplink API into UoM compliant strings.
*
* @param originalUnit
* @return UoM compliant unit
*/
public static String fixUnit(String originalUnit) {
return switch (originalUnit) {
case "l/m" -> "l/min";
case "hrs" -> "h";
case "m3/h" -> "m³/h";
case "days" -> "d";
default -> originalUnit;
};
}
}

View File

@ -0,0 +1,339 @@
/**
* 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.myuplink.internal.command;
import static org.openhab.binding.myuplink.internal.MyUplinkBindingConstants.*;
import java.net.SocketTimeoutException;
import java.net.UnknownHostException;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
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.Request;
import org.eclipse.jetty.client.api.Response;
import org.eclipse.jetty.client.api.Result;
import org.eclipse.jetty.client.util.BufferingResponseListener;
import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.http.HttpMethod;
import org.eclipse.jetty.http.HttpStatus;
import org.eclipse.jetty.http.HttpStatus.Code;
import org.openhab.binding.myuplink.internal.connector.CommunicationStatus;
import org.openhab.binding.myuplink.internal.handler.MyUplinkThingHandler;
import org.openhab.binding.myuplink.internal.model.GenericResponseTransformer;
import org.openhab.binding.myuplink.internal.model.ResponseTransformer;
import org.openhab.binding.myuplink.internal.model.ValidationException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.ToNumberPolicy;
/**
* base class for all commands. common logic should be implemented here
*
* @author Alexander Friese - initial contribution
*/
@NonNullByDefault
public abstract class AbstractCommand extends BufferingResponseListener implements MyUplinkCommand {
public enum RetryOnFailure {
YES,
NO
}
public enum ProcessFailureResponse {
YES,
NO
}
/**
* logger
*/
private final Logger logger = LoggerFactory.getLogger(AbstractCommand.class);
/**
* the configuration
*/
protected final MyUplinkThingHandler handler;
/**
* JSON deserializer
*/
protected final Gson gson;
/**
* status code of fulfilled request
*/
private final CommunicationStatus communicationStatus;
/**
* generic transformer which just transfers all values in a plain map.
*/
protected final ResponseTransformer transformer;
/**
* retry counter.
*/
private int retries = 0;
/**
* retry active
*/
private final RetryOnFailure retryOnFailure;
/**
* process error response, e.g. set handler offline on error
*/
private final ProcessFailureResponse processFailureResponse;
/**
* allows further processing of the json result data, if set.
*/
private final JsonResultProcessor resultProcessor;
/**
* the constructor
*/
public AbstractCommand(MyUplinkThingHandler handler, RetryOnFailure retryOnFailure,
ProcessFailureResponse processFailureResponse, JsonResultProcessor resultProcessor) {
this(handler, new GenericResponseTransformer(handler), retryOnFailure, processFailureResponse, resultProcessor);
}
public AbstractCommand(MyUplinkThingHandler handler, ResponseTransformer responseTransformer,
RetryOnFailure retryOnFailure, ProcessFailureResponse processFailureResponse,
JsonResultProcessor resultProcessor) {
this.gson = new GsonBuilder().setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE).create();
this.communicationStatus = new CommunicationStatus();
this.transformer = responseTransformer;
this.handler = handler;
this.processFailureResponse = processFailureResponse;
this.retryOnFailure = retryOnFailure;
this.resultProcessor = resultProcessor;
}
/**
* Log request success
*/
@Override
public final void onSuccess(@Nullable Response response) {
if (response != null) {
super.onSuccess(response);
communicationStatus.setHttpCode(HttpStatus.getCode(response.getStatus()));
logger.debug("[{}] HTTP response {}", getClass().getSimpleName(), response.getStatus());
}
}
/**
* Log request failure
*/
@Override
public final void onFailure(@Nullable Response response, @Nullable Throwable failure) {
if (failure != null && response != null) {
super.onFailure(response, failure);
}
if (failure != null) {
logger.info("[{}] Request failed: {}", getClass().getSimpleName(), failure.toString());
communicationStatus.setError((Exception) failure);
if (failure instanceof SocketTimeoutException || failure instanceof TimeoutException) {
communicationStatus.setHttpCode(Code.REQUEST_TIMEOUT);
} else if (failure instanceof UnknownHostException) {
communicationStatus.setHttpCode(Code.BAD_GATEWAY);
} else {
communicationStatus.setHttpCode(Code.INTERNAL_SERVER_ERROR);
}
} else {
logger.warn("[{}] Request failed", getClass().getSimpleName());
}
if (response != null && response.getStatus() > 0) {
communicationStatus.setHttpCode(HttpStatus.getCode(response.getStatus()));
}
}
/**
* just for logging of content
*/
@Override
public void onContent(@Nullable Response response, @Nullable ByteBuffer content) {
if (response != null && content != null) {
super.onContent(response, content);
}
var contentAsString = getContentAsString();
var contentLength = contentAsString == null ? 0 : contentAsString.length();
logger.debug("[{}] received content, length: {}, encoding: {}", getClass().getSimpleName(), contentLength,
this.getEncoding());
}
/**
* default handling of successful requests.
*/
@Override
public void onComplete(@Nullable Result result) {
String json = getContentAsString(StandardCharsets.UTF_8);
logger.debug("[{}] JSON String: {}", getClass().getSimpleName(), json);
switch (getCommunicationStatus().getHttpCode()) {
case OK:
case ACCEPTED:
onCompleteCodeOk(json);
break;
default:
onCompleteCodeDefault(json);
}
}
/**
* handling of result in case of HTTP response OK.
*
* @param json
*/
protected void onCompleteCodeOk(@Nullable String json) {
JsonObject jsonObject = transform(json);
if (jsonObject != null) {
logger.debug("[{}] success", getClass().getSimpleName());
handler.updateChannelStatus(transformer.transform(jsonObject, getChannelGroup()));
processResult(jsonObject);
}
}
/**
* handling of result in default case, this means error handling of http codes where no specific handling applies.
*
* @param json
*/
protected void onCompleteCodeDefault(@Nullable String json) {
JsonObject jsonObject = transform(json);
if (jsonObject == null) {
jsonObject = new JsonObject();
}
if (processFailureResponse == ProcessFailureResponse.YES) {
processResult(jsonObject);
} else {
logger.warn("command failed, url: {} - code: {} - result: {}", getURL(),
getCommunicationStatus().getHttpCode(), jsonObject.toString());
}
if (retryOnFailure == RetryOnFailure.YES && retries++ < MAX_RETRIES) {
handler.enqueueCommand(this);
}
}
/**
* error safe json transformer.
*
* @param json
* @return
*/
protected @Nullable JsonObject transform(@Nullable String json) {
if (json != null) {
try {
JsonElement jsonElement = gson.fromJson(json, JsonElement.class);
JsonObject jsonObject;
if (jsonElement instanceof JsonObject) {
jsonObject = jsonElement.getAsJsonObject();
} else {
jsonObject = new JsonObject();
jsonObject.add(JSON_KEY_ROOT_DATA, jsonElement);
}
return jsonObject;
} catch (Exception ex) {
logger.debug("[{}] JSON could not be parsed: {}\nError: {}", getClass().getSimpleName(), json,
ex.getMessage());
}
}
return null;
}
/**
* preparation of the request. will call a hook (prepareRequest) that has to be implemented in the subclass to add
* content to the request.
*
* @throws ValidationException
*/
@Override
public void performAction(HttpClient asyncclient, String accessToken) throws ValidationException {
Request request = asyncclient.newRequest(getURL()).timeout(handler.getBridgeConfiguration().getAsyncTimeout(),
TimeUnit.SECONDS);
logger.debug("[{}] running command", getClass().getSimpleName());
// we want to receive json only, so explicitely set this!
request.header(HttpHeader.ACCEPT, "application/json");
request.header(HttpHeader.ACCEPT_ENCODING, StandardCharsets.UTF_8.name());
// this should be the default for myUplink Cloud API
request.followRedirects(false);
// add authentication data for every request. Handling this here makes it obsolete to implement for each and
// every command
if (!accessToken.isBlank()) {
request.header(HttpHeader.AUTHORIZATION, WEB_REQUEST_BEARER_TOKEN_PREFIX + accessToken);
}
prepareRequest(request).send(this);
}
/**
* @return returns Http Status Code
*/
public CommunicationStatus getCommunicationStatus() {
return communicationStatus;
}
/**
* calls the registered resultProcessor.
*
* @param jsonObject
*/
protected final void processResult(JsonObject jsonObject) {
try {
resultProcessor.processResult(getCommunicationStatus(), jsonObject);
} catch (Exception ex) {
// this should not happen
logger.warn("[{}] Exception caught: {}", getClass().getSimpleName(), ex.getMessage(), ex);
}
}
/**
* default implementation just assumes that we want to retrieve data via GET.
* can be overridden for any special case and has to prepare the requests with additional parameters, etc
*
* @param requestToPrepare the request to prepare
* @return prepared Request object
* @throws ValidationException
*/
protected Request prepareRequest(Request requestToPrepare) throws ValidationException {
requestToPrepare.method(HttpMethod.GET);
return requestToPrepare;
}
/**
* concrete implementation has to provide the channel group.
*
* @return
*/
protected abstract String getChannelGroup();
/**
* concrete implementation has to provide the URL
*
* @return Url
*/
protected abstract String getURL();
}

View File

@ -0,0 +1,42 @@
/**
* 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.myuplink.internal.command;
import static org.openhab.binding.myuplink.internal.MyUplinkBindingConstants.*;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jetty.client.api.Request;
import org.eclipse.jetty.http.HttpMethod;
import org.openhab.binding.myuplink.internal.handler.MyUplinkThingHandler;
import org.openhab.binding.myuplink.internal.model.ValidationException;
/**
* base class for all commands that support paging. common logic should be implemented here
*
* @author Alexander Friese - initial contribution
*/
@NonNullByDefault
public abstract class AbstractPagingCommand extends AbstractCommand {
public AbstractPagingCommand(MyUplinkThingHandler handler, RetryOnFailure retryOnFailure,
ProcessFailureResponse processFailureResponse, JsonResultProcessor resultProcessor) {
super(handler, retryOnFailure, processFailureResponse, resultProcessor);
}
@Override
protected Request prepareRequest(Request requestToPrepare) throws ValidationException {
requestToPrepare.param(WEB_REQUEST_PARAM_PAGE_SIZE_KEY, String.valueOf(WEB_REQUEST_PARAM_PAGE_SIZE_VALUE));
requestToPrepare.method(HttpMethod.GET);
return requestToPrepare;
}
}

View File

@ -0,0 +1,151 @@
/**
* 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.myuplink.internal.command;
import static org.openhab.binding.myuplink.internal.MyUplinkBindingConstants.EMPTY;
import java.util.HashMap;
import java.util.Map;
import javax.measure.MetricPrefix;
import javax.measure.Unit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jetty.client.api.Request;
import org.openhab.binding.myuplink.internal.Utils;
import org.openhab.binding.myuplink.internal.handler.MyUplinkThingHandler;
import org.openhab.binding.myuplink.internal.model.ValidationException;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.unit.SIUnits;
import org.openhab.core.library.unit.Units;
import org.openhab.core.thing.Channel;
import org.openhab.core.types.Command;
import org.openhab.core.types.UnDefType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* base class for all write commands. common logic should be implemented here
*
* @author Alexander Friese - initial contribution
*/
@NonNullByDefault
public abstract class AbstractWriteCommand extends AbstractCommand {
private final Logger logger = LoggerFactory.getLogger(AbstractWriteCommand.class);
protected final Channel channel;
protected final Command command;
/**
* the constructor
*/
public AbstractWriteCommand(MyUplinkThingHandler handler, Channel channel, Command command,
RetryOnFailure retryOnFailure, ProcessFailureResponse processFailureResponse,
JsonResultProcessor resultProcessor) {
super(handler, retryOnFailure, processFailureResponse, resultProcessor);
this.channel = channel;
this.command = command;
}
/**
* helper method for write commands that extracts value from command.
*
* @return value as String without unit.
*/
protected String getCommandValue() {
if (command instanceof QuantityType<?> quantityCommand) {
// this is necessary because we must not send the unit to the backend
Unit<?> unit = quantityCommand.getUnit();
QuantityType<?> convertedType;
if (unit.isCompatible(SIUnits.CELSIUS)) {
convertedType = quantityCommand.toUnit(SIUnits.CELSIUS);
} else if (unit.isCompatible(Units.KILOWATT_HOUR)) {
convertedType = quantityCommand.toUnit(Units.KILOWATT_HOUR);
} else if (unit.isCompatible(Units.LITRE_PER_MINUTE)) {
convertedType = quantityCommand.toUnit(Units.LITRE_PER_MINUTE);
} else if (unit.isCompatible(tech.units.indriya.unit.Units.WATT)) {
convertedType = quantityCommand.toUnit(MetricPrefix.KILO(tech.units.indriya.unit.Units.WATT));
} else {
logger.warn("automatic conversion of unit '{}' to myUplink expected unit not supported.",
unit.getName());
convertedType = quantityCommand;
}
return String.valueOf(convertedType != null ? convertedType.doubleValue() : UnDefType.NULL);
} else if (command instanceof OnOffType onOffType) {
// this is necessary because we must send 0/1 and not ON/OFF to the backend
return OnOffType.ON.equals(onOffType) ? "1" : "0";
} else {
return command.toString();
}
}
/**
* helper that transforms channelId + commandvalue in a JSON string that can be added as content to a POST request.
*
* @return converted JSON string
*/
protected String getJsonContent() {
return buildJsonObject(channel.getUID().getIdWithoutGroup(), getCommandValue());
}
/**
* helper that creates a simple json object as string.
*
* @param key identifier of the value
* @param value the value to assign to the key
*
* @return converted JSON string
*/
protected String buildJsonObject(String key, String value) {
Map<String, String> content = new HashMap<>(1);
content.put(key, value);
return gson.toJson(content);
}
@Override
protected Request prepareRequest(Request requestToPrepare) throws ValidationException {
String channelId = channel.getUID().getIdWithoutGroup();
String expr = Utils.getValidationExpression(channel);
String value = getCommandValue();
// quantity types are transformed to double and thus we might have decimals which could cause validation error.
// So we will shorten here in case no decimals are needed.
if (value.endsWith(".0")) {
value = value.substring(0, value.length() - 2);
}
if (value.matches(expr)) {
return prepareWriteRequest(requestToPrepare);
} else {
logger.debug("channel '{}' does not allow value '{}' - validation rule '{}'", channelId, value, expr);
throw new ValidationException("channel (" + channelId + ") could not be updated due to a validation error");
}
}
@Override
protected String getChannelGroup() {
// this is a pure write command, thus no channel group needed.
return EMPTY;
}
/**
* concrete implementation has to prepare the write requests with additional parameters, etc
*
* @param requestToPrepare the request to prepare
* @return prepared Request object
* @throws ValidationException
*/
protected abstract Request prepareWriteRequest(Request requestToPrepare) throws ValidationException;
}

View File

@ -0,0 +1,39 @@
/**
* 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.myuplink.internal.command;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.myuplink.internal.connector.CommunicationStatus;
import com.google.gson.JsonObject;
/**
* functional interface that is intended to provide a function for further result processing of json data retrieved by a
* command.
*
* @author Alexander Friese - initial contribution
*/
@NonNullByDefault
@FunctionalInterface
public interface JsonResultProcessor {
/**
* this method processes the result of the myUplink API call.
*
* @param status
* technical communication status of the http call.
* @param jsonObject
* json response of the http call
*/
void processResult(CommunicationStatus status, JsonObject jsonObject);
}

View File

@ -0,0 +1,40 @@
/**
* 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.myuplink.internal.command;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.api.Response.CompleteListener;
import org.eclipse.jetty.client.api.Response.ContentListener;
import org.eclipse.jetty.client.api.Response.FailureListener;
import org.eclipse.jetty.client.api.Response.SuccessListener;
import org.openhab.binding.myuplink.internal.model.ValidationException;
/**
* public interface for all commands
*
* @author Alexander Friese - initial contribution
*/
@NonNullByDefault
public interface MyUplinkCommand extends SuccessListener, FailureListener, ContentListener, CompleteListener {
static final int MAX_RETRIES = 5;
/**
* this method is to be called by the UplinkWebinterface class
*
* @param asyncclient
* @throws ValidationException
*/
void performAction(HttpClient asyncclient, String token) throws ValidationException;
}

View File

@ -0,0 +1,62 @@
/**
* 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.myuplink.internal.command.account;
import static org.openhab.binding.myuplink.internal.MyUplinkBindingConstants.*;
import java.nio.charset.StandardCharsets;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.api.Result;
import org.openhab.binding.myuplink.internal.command.AbstractPagingCommand;
import org.openhab.binding.myuplink.internal.command.JsonResultProcessor;
import org.openhab.binding.myuplink.internal.handler.MyUplinkThingHandler;
import com.google.gson.JsonObject;
/**
* implements the get sites api call of the site.
*
* @author Alexander Friese - initial contribution
*/
@NonNullByDefault
public class GetSystems extends AbstractPagingCommand {
public GetSystems(MyUplinkThingHandler handler, JsonResultProcessor resultProcessor) {
// retry does not make much sense as it is a polling command, command should always succeed therefore update
// handler on failure.
super(handler, RetryOnFailure.NO, ProcessFailureResponse.YES, resultProcessor);
}
@Override
protected String getURL() {
String url = GET_SYSTEMS_URL;
return url;
}
@Override
public void onComplete(@Nullable Result result) {
String json = getContentAsString(StandardCharsets.UTF_8);
JsonObject jsonObject = gson.fromJson(json, JsonObject.class);
if (jsonObject != null) {
processResult(jsonObject);
}
}
@Override
protected String getChannelGroup() {
return EMPTY;
}
}

View File

@ -0,0 +1,86 @@
/**
* 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.myuplink.internal.command.account;
import static org.openhab.binding.myuplink.internal.MyUplinkBindingConstants.*;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.api.Request;
import org.eclipse.jetty.client.api.Result;
import org.eclipse.jetty.client.util.FormContentProvider;
import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.http.HttpMethod;
import org.eclipse.jetty.util.Fields;
import org.openhab.binding.myuplink.internal.command.AbstractCommand;
import org.openhab.binding.myuplink.internal.command.JsonResultProcessor;
import org.openhab.binding.myuplink.internal.handler.MyUplinkBridgeHandler;
import com.google.gson.JsonObject;
/**
* implements the login to the webinterface
*
* @author Alexander Friese - initial contribution
*/
@NonNullByDefault
public class Login extends AbstractCommand {
private final String encodedLogin;
public Login(MyUplinkBridgeHandler handler, JsonResultProcessor resultProcessor) {
// flags do not matter as "onComplete" is overwritten in this class.
super(handler, RetryOnFailure.NO, ProcessFailureResponse.NO, resultProcessor);
String login = handler.getBridgeConfiguration().getClientId() + ":"
+ handler.getBridgeConfiguration().getClientSecret();
encodedLogin = Base64.getEncoder().encodeToString(login.getBytes(StandardCharsets.UTF_8));
}
@Override
protected Request prepareRequest(Request requestToPrepare) {
Fields fields = new Fields();
fields.add(LOGIN_FIELD_GRANT_TYPE_KEY, LOGIN_FIELD_GRANT_TYPE_VALUE);
fields.add(LOGIN_FIELD_SCOPE_KEY, LOGIN_FIELD_SCOPE_VALUE);
FormContentProvider cp = new FormContentProvider(fields, StandardCharsets.UTF_8);
requestToPrepare.header(HttpHeader.AUTHORIZATION, LOGIN_BASIC_AUTH_PREFIX + encodedLogin);
requestToPrepare.content(cp);
requestToPrepare.method(HttpMethod.POST);
return requestToPrepare;
}
@Override
protected String getURL() {
return LOGIN_URL;
}
@Override
public void onComplete(@Nullable Result result) {
String json = getContentAsString(StandardCharsets.UTF_8);
JsonObject jsonObject = gson.fromJson(json, JsonObject.class);
if (jsonObject != null) {
processResult(jsonObject);
}
}
@Override
protected String getChannelGroup() {
return EMPTY;
}
}

View File

@ -0,0 +1,47 @@
/**
* 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.myuplink.internal.command.device;
import static org.openhab.binding.myuplink.internal.MyUplinkBindingConstants.*;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.myuplink.internal.command.AbstractCommand;
import org.openhab.binding.myuplink.internal.command.JsonResultProcessor;
import org.openhab.binding.myuplink.internal.handler.MyUplinkThingHandler;
/**
* implements the get points api call of the myUplink API.
*
* @author Alexander Friese - initial contribution
*/
@NonNullByDefault
public class GetPoints extends AbstractCommand {
private final String url;
public GetPoints(MyUplinkThingHandler handler, String deviceId, JsonResultProcessor resultProcessor) {
// retry does not make much sense as it is a polling command, command should always succeed therefore update
// handler on failure.
super(handler, RetryOnFailure.NO, ProcessFailureResponse.YES, resultProcessor);
this.url = GET_DEVICE_POINTS.replaceAll("\\{deviceId\\}", deviceId);
}
@Override
protected String getURL() {
return url;
}
@Override
protected String getChannelGroup() {
return EMPTY;
}
}

View File

@ -0,0 +1,49 @@
/**
* 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.myuplink.internal.command.device;
import static org.openhab.binding.myuplink.internal.MyUplinkBindingConstants.*;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.myuplink.internal.command.AbstractCommand;
import org.openhab.binding.myuplink.internal.command.JsonResultProcessor;
import org.openhab.binding.myuplink.internal.handler.MyUplinkThingHandler;
import org.openhab.binding.myuplink.internal.model.SmartHomeModeResponseTransformer;
/**
* implements the get sites api call of the site.
*
* @author Alexander Friese - initial contribution
*/
@NonNullByDefault
public class GetSmartHomeMode extends AbstractCommand {
private String url;
public GetSmartHomeMode(MyUplinkThingHandler handler, String systemId, JsonResultProcessor resultProcessor) {
// retry does not make much sense as it is a polling command, command should always succeed therefore update
// handler on failure.
super(handler, new SmartHomeModeResponseTransformer(handler), RetryOnFailure.NO, ProcessFailureResponse.YES,
resultProcessor);
this.url = GET_SMART_HOME_MODE_URL.replaceAll("\\{systemId\\}", systemId);
}
@Override
protected String getURL() {
return this.url;
}
@Override
protected String getChannelGroup() {
return EMPTY;
}
}

View File

@ -0,0 +1,61 @@
/**
* 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.myuplink.internal.command.device;
import static org.openhab.binding.myuplink.internal.MyUplinkBindingConstants.*;
import java.nio.charset.StandardCharsets;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jetty.client.api.Request;
import org.eclipse.jetty.client.util.StringContentProvider;
import org.eclipse.jetty.http.HttpMethod;
import org.openhab.binding.myuplink.internal.command.AbstractWriteCommand;
import org.openhab.binding.myuplink.internal.command.JsonResultProcessor;
import org.openhab.binding.myuplink.internal.handler.MyUplinkThingHandler;
import org.openhab.binding.myuplink.internal.model.ValidationException;
import org.openhab.core.thing.Channel;
import org.openhab.core.types.Command;
/**
* implements the set points api call of the myUplink API.
*
* @author Alexander Friese - initial contribution
*/
@NonNullByDefault
public class SetPoints extends AbstractWriteCommand {
private final String url;
public SetPoints(MyUplinkThingHandler handler, Channel channel, Command command, String deviceId,
JsonResultProcessor resultProcessor) {
super(handler, channel, command, RetryOnFailure.YES, ProcessFailureResponse.YES, resultProcessor);
this.url = SET_DEVICE_POINTS.replaceAll("\\{deviceId\\}", deviceId);
}
@Override
protected String getURL() {
return url;
}
@Override
protected Request prepareWriteRequest(Request requestToPrepare) throws ValidationException {
requestToPrepare.method(HttpMethod.PATCH);
StringContentProvider cp = new StringContentProvider(WEB_REQUEST_PATCH_CONTENT_TYPE, getJsonContent(),
StandardCharsets.UTF_8);
requestToPrepare.content(cp);
return requestToPrepare;
}
}

View File

@ -0,0 +1,105 @@
/**
* 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.myuplink.internal.command.device;
import static org.openhab.binding.myuplink.internal.MyUplinkBindingConstants.*;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.api.Request;
import org.eclipse.jetty.client.util.StringContentProvider;
import org.eclipse.jetty.http.HttpMethod;
import org.openhab.binding.myuplink.internal.command.AbstractWriteCommand;
import org.openhab.binding.myuplink.internal.command.JsonResultProcessor;
import org.openhab.binding.myuplink.internal.handler.MyUplinkThingHandler;
import org.openhab.binding.myuplink.internal.model.ValidationException;
import org.openhab.core.library.types.StringType;
import org.openhab.core.thing.Channel;
import org.openhab.core.types.Command;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
/**
* implements the set points api call of the API. Extracts channel ID and value from the command string. Needed by the
* "generic command" channel.
*
* @author Alexander Friese - initial contribution
*/
@NonNullByDefault
public class SetPointsAdvanced extends AbstractWriteCommand {
private final String url;
public SetPointsAdvanced(MyUplinkThingHandler handler, Channel channel, Command command, String deviceId,
JsonResultProcessor resultProcessor) {
super(handler, channel, command, RetryOnFailure.YES, ProcessFailureResponse.YES, resultProcessor);
this.url = SET_DEVICE_POINTS.replaceAll("\\{deviceId\\}", deviceId);
}
@Override
protected String getURL() {
return url;
}
@Override
protected Request prepareWriteRequest(Request requestToPrepare) throws ValidationException {
requestToPrepare.method(HttpMethod.PATCH);
StringContentProvider cp = new StringContentProvider(WEB_REQUEST_PATCH_CONTENT_TYPE,
buildJsonObject(getChannelId(), getChannelValue()), StandardCharsets.UTF_8);
requestToPrepare.content(cp);
return requestToPrepare;
}
private String getChannelId() {
if (command instanceof StringType stringCommand) {
String[] tokens = stringCommand.toString().split(":");
return tokens.length == 2 ? tokens[0] : command.toString();
} else {
return command.toString();
}
}
private String getChannelValue() {
if (command instanceof StringType stringCommand) {
String[] tokens = stringCommand.toString().split(":");
return tokens.length == 2 ? tokens[1] : command.toString();
} else {
return command.toString();
}
}
/**
* handling of result in case of HTTP response OK.
*
* @param json
*/
protected void onCompleteCodeOk(@Nullable String json) {
Map<String, String> content = new HashMap<>(2);
content.put(JSON_KEY_CHANNEL_ID, CHANNEL_ID_COMMAND);
content.put(JSON_KEY_CHANNEL_VALUE, getCommunicationStatus().getHttpCode().name());
content.put(JSON_KEY_ROOT_DATA, json == null ? EMPTY : json);
var jsonObjectString = gson.toJson(content);
var jsonObject = gson.fromJson(jsonObjectString, JsonObject.class);
var jsonArray = new JsonArray();
jsonArray.add(jsonObject);
super.onCompleteCodeOk(gson.toJson(jsonArray));
}
}

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.myuplink.internal.command.device;
import static org.openhab.binding.myuplink.internal.MyUplinkBindingConstants.*;
import java.nio.charset.StandardCharsets;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jetty.client.api.Request;
import org.eclipse.jetty.client.util.StringContentProvider;
import org.eclipse.jetty.http.HttpMethod;
import org.openhab.binding.myuplink.internal.command.AbstractWriteCommand;
import org.openhab.binding.myuplink.internal.command.JsonResultProcessor;
import org.openhab.binding.myuplink.internal.handler.MyUplinkThingHandler;
import org.openhab.binding.myuplink.internal.model.ValidationException;
import org.openhab.core.thing.Channel;
import org.openhab.core.types.Command;
/**
* implements the set smart home mode api call of the site.
*
* @author Anders Alfredsson - initial contribution
*/
@NonNullByDefault
public class SetSmartHomeMode extends AbstractWriteCommand {
private String url;
public SetSmartHomeMode(MyUplinkThingHandler handler, Channel channel, Command command, String systemId,
JsonResultProcessor resultProcessor) {
// retry does not make much sense as it is a polling command, command should always succeed therefore update
// handler on failure.
super(handler, channel, command, RetryOnFailure.NO, ProcessFailureResponse.YES, resultProcessor);
this.url = SET_SMART_HOME_MODE_URL.replaceAll("\\{systemId\\}", systemId);
}
@Override
protected String getURL() {
return this.url;
}
@Override
protected Request prepareWriteRequest(Request requestToPrepare) throws ValidationException {
requestToPrepare.method(HttpMethod.PUT);
String body = buildJsonObject(JSON_KEY_SMART_HOME_MODE, command.toString());
StringContentProvider cp = new StringContentProvider(WEB_REQUEST_PATCH_CONTENT_TYPE, body,
StandardCharsets.UTF_8);
requestToPrepare.content(cp);
return requestToPrepare;
}
}

View File

@ -0,0 +1,95 @@
/**
* 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.myuplink.internal.config;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@link MyUplinkConfiguration} class contains fields mapping thing configuration parameters.
*
* @author Alexander Friese - Initial contribution
*/
@NonNullByDefault
public class MyUplinkConfiguration {
private String clientId = "";
private String clientSecret = "";
private int dataPollingInterval = 60;
private static final int ASYNC_TIMEOUT = 120;
private static final int SYNC_TIMEOUT = 120;
/**
* @return the clientId
*/
public String getClientId() {
return clientId;
}
/**
* @param clientId the clientId to set
*/
public void setClientId(String clientId) {
this.clientId = clientId;
}
/**
* @return the clientSecret
*/
public String getClientSecret() {
return clientSecret;
}
/**
* @param clientSecret the clientSecret to set
*/
public void setClientSecret(String clientSecret) {
this.clientSecret = clientSecret;
}
/**
* @return the asyncTimeout
*/
public Integer getAsyncTimeout() {
return ASYNC_TIMEOUT;
}
/**
* @return the syncTimeout
*/
public Integer getSyncTimeout() {
return SYNC_TIMEOUT;
}
/**
* @return the dataPollingInterval
*/
public Integer getDataPollingInterval() {
return dataPollingInterval;
}
/**
* @param dataPollingInterval the dataPollingInterval to set
*/
public void setDataPollingInterval(Integer dataPollingInterval) {
this.dataPollingInterval = dataPollingInterval;
}
@Override
public String toString() {
StringBuilder builder = new StringBuilder();
builder.append("MyUplinkConfiguration [clientId=").append(clientId).append(", clientSecret=")
.append(clientSecret).append(", dataPollingInterval=").append(dataPollingInterval).append("]");
return builder.toString();
}
}

View File

@ -0,0 +1,59 @@
/**
* 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.myuplink.internal.connector;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.http.HttpStatus.Code;
/**
* this class contains the HTTP status of the communication and an optional exception that might occoured during
* communication
*
* @author Alexander Friese - initial contribution
*/
@NonNullByDefault
public class CommunicationStatus {
private @Nullable Code httpCode;
private @Nullable Exception error;
public final Code getHttpCode() {
Code code = httpCode;
return code == null ? Code.INTERNAL_SERVER_ERROR : code;
}
public final void setHttpCode(Code httpCode) {
this.httpCode = httpCode;
}
public final @Nullable Exception getError() {
return error;
}
public final void setError(Exception error) {
this.error = error;
}
public final String getMessage() {
Exception err = error;
String errMsg = err == null ? null : err.getMessage();
String msg = getHttpCode().getMessage();
if (errMsg != null && !errMsg.isEmpty()) {
return errMsg;
} else if (msg != null && !msg.isEmpty()) {
return msg;
}
return "";
}
}

View File

@ -0,0 +1,316 @@
/**
* 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.myuplink.internal.connector;
import static org.openhab.binding.myuplink.internal.MyUplinkBindingConstants.*;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Queue;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.util.BlockingArrayQueue;
import org.openhab.binding.myuplink.internal.AtomicReferenceTrait;
import org.openhab.binding.myuplink.internal.Utils;
import org.openhab.binding.myuplink.internal.command.MyUplinkCommand;
import org.openhab.binding.myuplink.internal.command.account.Login;
import org.openhab.binding.myuplink.internal.handler.MyUplinkBridgeHandler;
import org.openhab.binding.myuplink.internal.handler.StatusHandler;
import org.openhab.binding.myuplink.internal.model.ValidationException;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.JsonObject;
/**
* The connector is responsible for communication with the NIBE myUplink Cloud API
*
* @author Alexander Friese - initial contribution
*/
@NonNullByDefault
public class WebInterface implements AtomicReferenceTrait {
private final Logger logger = LoggerFactory.getLogger(WebInterface.class);
/**
* bridge handler
*/
private final MyUplinkBridgeHandler handler;
/**
* handler for updating bridge status
*/
private final StatusHandler bridgeStatusHandler;
/**
* holds authentication status
*/
private boolean authenticated = false;
/**
* access token returned by login, needed to authenticate all requests send to API.
*/
private String accessToken;
/**
* expiry of the access token.
*/
private Instant tokenExpiry;
/**
* last refresh of the access token.
*/
private Instant tokenRefreshDate;
/**
* HTTP client for asynchronous calls
*/
private final HttpClient httpClient;
/**
* the scheduler which periodically sends web requests to the NIBE myUplink Cloud API. Should be initiated with the
* thing's
* existing scheduler instance.
*/
private final ScheduledExecutorService scheduler;
/**
* request executor
*/
private final WebRequestExecutor requestExecutor;
/**
* periodic request executor job
*/
private final AtomicReference<@Nullable Future<?>> requestExecutorJobReference;
/**
* this class is responsible for executing periodic web requests. This ensures that only one request is executed
* at the same time and there will be a guaranteed minimum delay between subsequent requests.
*
* @author afriese - initial contribution
*/
private class WebRequestExecutor implements Runnable {
/**
* queue which holds the commands to execute
*/
private final Queue<MyUplinkCommand> commandQueue;
/**
* constructor
*/
WebRequestExecutor() {
this.commandQueue = new BlockingArrayQueue<>(WEB_REQUEST_QUEUE_MAX_SIZE);
}
private void processAuthenticationResult(CommunicationStatus status, JsonObject jsonObject) {
String msg = Utils.getAsString(jsonObject, JSON_KEY_ERROR);
if (msg == null || msg.isBlank()) {
msg = status.getMessage();
}
switch (status.getHttpCode()) {
case BAD_REQUEST:
bridgeStatusHandler.updateStatusInfo(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
msg);
setAuthenticated(false);
break;
case OK:
String accessToken = Utils.getAsString(jsonObject, JSON_KEY_AUTH_ACCESS_TOKEN);
int expiresInSeconds = Utils.getAsInt(jsonObject, JSON_KEY_AUTH_EXPIRES_IN);
if (accessToken != null && expiresInSeconds != 0) {
WebInterface.this.accessToken = accessToken;
tokenRefreshDate = Instant.now();
tokenExpiry = tokenRefreshDate.plusSeconds(expiresInSeconds);
logger.debug("access token refreshed: {}, expiry: {}", tokenRefreshDate.toString(),
tokenExpiry.toString());
bridgeStatusHandler.updateStatusInfo(ThingStatus.ONLINE, ThingStatusDetail.NONE,
STATUS_TOKEN_VALIDATED);
setAuthenticated(true);
handler.startDiscovery();
break;
}
default:
bridgeStatusHandler.updateStatusInfo(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
msg);
setAuthenticated(false);
}
}
/**
* puts a command into the queue
*
* @param command
*/
void enqueue(MyUplinkCommand command) {
try {
commandQueue.add(command);
} catch (IllegalStateException ex) {
if (commandQueue.size() >= WEB_REQUEST_QUEUE_MAX_SIZE) {
logger.debug(
"Could not add command to command queue because queue is already full. Maybe NIBE myUplink is down?");
} else {
logger.warn("Could not add command to queue - IllegalStateException");
}
}
}
/**
* executes the web request
*/
@Override
public void run() {
logger.debug("run queued commands, queue size is {}", commandQueue.size());
if (!isAuthenticated()) {
authenticate();
} else {
refreshAccessToken();
if (isAuthenticated() && !commandQueue.isEmpty()) {
try {
executeCommand();
} catch (Exception ex) {
logger.warn("command execution ended with exception:", ex);
}
}
}
}
/**
* authenticates with the NIBE myUplink Cloud interface.
*/
private synchronized void authenticate() {
setAuthenticated(false);
MyUplinkCommand loginCommand = new Login(handler, this::processAuthenticationResult);
try {
loginCommand.performAction(httpClient, "");
} catch (ValidationException e) {
// this cannot happen
}
}
/**
* periodically refreshed the access token.
*/
private synchronized void refreshAccessToken() {
Instant now = Instant.now();
if (now.plus(WEB_REQUEST_TOKEN_EXPIRY_BUFFER_MINUTES, ChronoUnit.MINUTES).isAfter(tokenExpiry)
|| now.isAfter(tokenRefreshDate.plus(WEB_REQUEST_TOKEN_MAX_AGE_MINUTES, ChronoUnit.MINUTES))) {
logger.debug("access token needs to be refreshed, last refresh: {}, expiry: {}",
tokenRefreshDate.toString(), tokenExpiry.toString());
MyUplinkCommand refreshCommand = new Login(handler, this::processAuthenticationResult);
try {
refreshCommand.performAction(httpClient, "");
} catch (ValidationException e) {
// this cannot happen
}
}
}
/**
* executes the next command in the queue. requires authenticated session.
*
* @throws ValidationException
*/
private void executeCommand() throws ValidationException {
MyUplinkCommand command = commandQueue.poll();
if (command != null) {
command.performAction(httpClient, accessToken);
}
}
}
/**
* Constructor to set up interface
*/
public WebInterface(ScheduledExecutorService scheduler, MyUplinkBridgeHandler handler, HttpClient httpClient,
StatusHandler bridgeStatusHandler) {
this.handler = handler;
this.bridgeStatusHandler = bridgeStatusHandler;
this.scheduler = scheduler;
this.httpClient = httpClient;
this.tokenExpiry = OUTDATED_DATE;
this.tokenRefreshDate = OUTDATED_DATE;
this.accessToken = "";
this.requestExecutor = new WebRequestExecutor();
this.requestExecutorJobReference = new AtomicReference<>(null);
}
public void start() {
setAuthenticated(false);
updateJobReference(requestExecutorJobReference, scheduler.scheduleWithFixedDelay(requestExecutor,
WEB_REQUEST_INITIAL_DELAY, WEB_REQUEST_INTERVAL, TimeUnit.SECONDS));
}
/**
* queues any command for execution
*
* @param command
*/
public void enqueueCommand(MyUplinkCommand command) {
requestExecutor.enqueue(command);
}
/**
* will be called by the ThingHandler to abort periodic jobs.
*/
public void dispose() {
logger.debug("Webinterface disposed.");
cancelJobReference(requestExecutorJobReference);
setAuthenticated(false);
}
/**
* returns authentication status.
*
* @return
*/
private boolean isAuthenticated() {
return authenticated;
}
/**
* update the authentication status, also resets token data.
*
* @param authenticated
*/
private void setAuthenticated(boolean authenticated) {
this.authenticated = authenticated;
if (!authenticated) {
this.tokenExpiry = OUTDATED_DATE;
this.accessToken = "";
}
}
/**
* returns the current access token.
*
* @return
*/
public String getAccessToken() {
return accessToken;
}
}

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.myuplink.internal.discovery;
import static org.openhab.binding.myuplink.internal.MyUplinkBindingConstants.*;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.myuplink.internal.MyUplinkBindingConstants;
import org.openhab.binding.myuplink.internal.Utils;
import org.openhab.binding.myuplink.internal.command.account.GetSystems;
import org.openhab.binding.myuplink.internal.connector.CommunicationStatus;
import org.openhab.binding.myuplink.internal.handler.MyUplinkAccountHandler;
import org.openhab.core.config.discovery.AbstractDiscoveryService;
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.ThingUID;
import org.openhab.core.thing.binding.ThingHandler;
import org.openhab.core.thing.binding.ThingHandlerService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
/**
* this class will handle discovery of wallboxes and circuits within the site configured.
*
* @author Alexander Friese - initial contribution
*
*/
@NonNullByDefault
public class MyUplinkDiscoveryService extends AbstractDiscoveryService implements ThingHandlerService {
private final Logger logger = LoggerFactory.getLogger(MyUplinkDiscoveryService.class);
private @NonNullByDefault({}) MyUplinkAccountHandler bridgeHandler;
public MyUplinkDiscoveryService() throws IllegalArgumentException {
super(Set.of(MyUplinkBindingConstants.THING_TYPE_ACCOUNT), 300, false);
}
@Override
public void setThingHandler(ThingHandler handler) {
if (handler instanceof MyUplinkAccountHandler accountHandler) {
this.bridgeHandler = accountHandler;
this.bridgeHandler.setDiscoveryService(this);
}
}
@Override
public @Nullable ThingHandler getThingHandler() {
return bridgeHandler;
}
// method is defined in both implemented interface and inherited class, thus we must define a behaviour here.
@Override
public void deactivate() {
super.deactivate();
}
@Override
protected void startScan() {
bridgeHandler.enqueueCommand(new GetSystems(bridgeHandler, this::processMyUplinkDiscoveryResult));
}
/**
* callback that handles json result data to provide discovery result.
*
* @param site
*/
void processMyUplinkDiscoveryResult(CommunicationStatus status, JsonObject json) {
logger.debug("processMyUplinkDiscoveryResult {}", json);
JsonArray systems = json.getAsJsonArray(JSON_KEY_SYSTEMS);
if (systems == null || systems.isEmpty()) {
logger.debug("System discovery finished, no systems found.");
} else {
systems.forEach(this::handleSystemDiscovery);
}
}
void handleSystemDiscovery(JsonElement json) {
logger.debug("handleSystemDiscovery {}", json);
JsonObject system = json.getAsJsonObject();
String systemId = Utils.getAsString(system, JSON_KEY_SYSTEM_ID);
JsonArray devices = system.getAsJsonArray(JSON_KEY_DEVICES);
if (devices == null || devices.isEmpty()) {
logger.debug("System discovery finished, no devices found.");
} else {
devices.forEach(device -> handleDeviceDiscovery(device, systemId));
}
}
void handleDeviceDiscovery(JsonElement json, @Nullable String systemId) {
logger.debug("handleDeviceDiscovery {}", json);
JsonObject device = json.getAsJsonObject();
String id = Utils.getAsString(device, JSON_KEY_GENERIC_ID);
String serial = Utils.getAsString(device.getAsJsonObject(JSON_KEY_PRODUCT), JSON_KEY_SERIAL);
String name = Utils.getAsString(device.getAsJsonObject(JSON_KEY_PRODUCT), JSON_KEY_NAME);
if (id != null && serial != null) {
DiscoveryResultBuilder builder;
builder = initDiscoveryResultBuilder(DEVICE_GENERIC_DEVICE, id, name);
builder.withProperty(THING_CONFIG_SERIAL, serial);
if (systemId != null) {
builder.withProperty(THING_CONFIG_SYSTEM_ID, systemId);
}
thingDiscovered(builder.build());
}
}
/**
* sends discovery notification to the framework.
*
* @param deviceType
* @param deviceId
* @param deviceName
*/
DiscoveryResultBuilder initDiscoveryResultBuilder(String deviceType, String deviceId, @Nullable String deviceName) {
ThingUID bridgeUID = bridgeHandler.getThing().getUID();
ThingTypeUID typeUid = new ThingTypeUID(BINDING_ID, deviceType);
ThingUID thingUID = new ThingUID(typeUid, bridgeUID, deviceId);
String label = deviceName != null ? deviceName : deviceId;
return DiscoveryResultBuilder.create(thingUID).withBridge(bridgeUID).withLabel(label)
.withProperty(THING_CONFIG_ID, deviceId).withRepresentationProperty(THING_CONFIG_ID);
}
}

View File

@ -0,0 +1,38 @@
/**
* 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.myuplink.internal.handler;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.thing.Channel;
/**
* this interface provides all methods which deal with channels
*
* @author Alexander Friese - initial contribution
*/
@NonNullByDefault
public interface ChannelProvider {
/**
* returns the channel with given channelId and groupId. If no channel matches, null is returned.
*
* @param groupId
* group ID of the channel
* @param channelId
* channel ID of the channel
* @return
*/
@Nullable
Channel getChannel(String groupId, String channelId);
}

View File

@ -0,0 +1,51 @@
/**
* 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.myuplink.internal.handler;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.myuplink.internal.provider.ChannelFactory;
import org.openhab.core.thing.Channel;
import org.openhab.core.thing.ThingUID;
/**
* this interface provides all methods which deal with channels
*
* @author Alexander Friese - initial contribution
*/
@NonNullByDefault
public interface DynamicChannelProvider {
/**
* registers a channel with the thing.
*
* @param channel
* the channel to be registered.
*/
void registerChannel(Channel channel);
/**
* Simple Getter to retrieve the Channelfactory of this thing.
*
* @return
* the ChannelFactory
*/
ChannelFactory getChannelFactory();
/**
* Simple Getter to retrieve the ThingUid
*
* @return
* the ThingUid
*/
ThingUID getThingUid();
}

View File

@ -0,0 +1,172 @@
/**
* 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.myuplink.internal.handler;
import static org.openhab.binding.myuplink.internal.MyUplinkBindingConstants.*;
import java.util.Collection;
import java.util.Set;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.openhab.binding.myuplink.internal.AtomicReferenceTrait;
import org.openhab.binding.myuplink.internal.Utils;
import org.openhab.binding.myuplink.internal.command.MyUplinkCommand;
import org.openhab.binding.myuplink.internal.command.account.GetSystems;
import org.openhab.binding.myuplink.internal.config.MyUplinkConfiguration;
import org.openhab.binding.myuplink.internal.connector.CommunicationStatus;
import org.openhab.binding.myuplink.internal.connector.WebInterface;
import org.openhab.binding.myuplink.internal.discovery.MyUplinkDiscoveryService;
import org.openhab.core.config.discovery.DiscoveryService;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.binding.BaseBridgeHandler;
import org.openhab.core.thing.binding.ThingHandlerService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.JsonObject;
/**
* The {@link myUplinkHandler} is responsible for handling commands, which are
* sent to one of the channels.
*
* @author Alexander Friese - Initial contribution
*/
@NonNullByDefault
public class MyUplinkAccountHandler extends BaseBridgeHandler implements MyUplinkBridgeHandler, AtomicReferenceTrait {
private final Logger logger = LoggerFactory.getLogger(MyUplinkAccountHandler.class);
/**
* Schedule for polling live data
*/
private final AtomicReference<@Nullable Future<?>> dataPollingJobReference;
private @Nullable DiscoveryService discoveryService;
/**
* Interface object for querying the NIBE myUplink API.
*/
private WebInterface webInterface;
public MyUplinkAccountHandler(Bridge bridge, HttpClient httpClient) {
super(bridge);
this.dataPollingJobReference = new AtomicReference<>(null);
this.webInterface = new WebInterface(scheduler, this, httpClient, super::updateStatus);
}
@Override
public void initialize() {
logger.debug("About to initialize myUplink Account");
MyUplinkConfiguration config = getBridgeConfiguration();
logger.debug("myUplink Account initialized with configuration: {}", config.toString());
updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.NONE, STATUS_WAITING_FOR_LOGIN);
if (config.getClientId().isBlank()) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, STATUS_CONFIG_ERROR_NO_CLIENT_ID);
} else if (config.getClientSecret().isBlank()) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
STATUS_CONFIG_ERROR_NO_CLIENT_SECRET);
} else {
webInterface.start();
startPolling();
}
}
/**
* Start the polling.
*/
private void startPolling() {
updateJobReference(dataPollingJobReference, scheduler.scheduleWithFixedDelay(this::pollingRun,
POLLING_INITIAL_DELAY, getBridgeConfiguration().getDataPollingInterval(), TimeUnit.SECONDS));
}
/**
* Poll the Easee Cloud API one time.
*/
void pollingRun() {
GetSystems state = new GetSystems(this, this::updateOnlineStatus);
enqueueCommand(state);
}
/**
* result processor to handle online status updates
*
* @param status of command execution
* @param jsonObject json respone result
*/
protected final void updateOnlineStatus(CommunicationStatus status, JsonObject jsonObject) {
String msg = Utils.getAsString(jsonObject, JSON_KEY_ERROR);
if (msg == null || msg.isBlank()) {
msg = status.getMessage();
}
switch (status.getHttpCode()) {
case OK:
case ACCEPTED:
super.updateStatus(ThingStatus.ONLINE);
break;
default:
super.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, msg);
}
}
/**
* Disposes the bridge.
*/
@Override
public void dispose() {
logger.debug("Handler disposing");
cancelJobReference(dataPollingJobReference);
webInterface.dispose();
}
@Override
public MyUplinkConfiguration getBridgeConfiguration() {
return this.getConfigAs(MyUplinkConfiguration.class);
}
@Override
public Collection<Class<? extends ThingHandlerService>> getServices() {
return Set.of(MyUplinkDiscoveryService.class);
}
public void setDiscoveryService(MyUplinkDiscoveryService discoveryService) {
this.discoveryService = discoveryService;
}
@Override
public void startDiscovery() {
DiscoveryService discoveryService = this.discoveryService;
if (discoveryService != null) {
discoveryService.startScan(null);
}
}
@Override
public void enqueueCommand(MyUplinkCommand command) {
webInterface.enqueueCommand(command);
}
@Override
public Logger getLogger() {
return logger;
}
}

View File

@ -0,0 +1,30 @@
/**
* 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.myuplink.internal.handler;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.thing.binding.BridgeHandler;
/**
* public interface of the {@link MyUplinkBridgeHandler}
*
* @author Alexander Friese - initial contribution
*/
@NonNullByDefault
public interface MyUplinkBridgeHandler extends BridgeHandler, MyUplinkThingHandler {
/**
* starts discovery of Nibe devices
*/
void startDiscovery();
}

View File

@ -0,0 +1,296 @@
/**
* 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.myuplink.internal.handler;
import static org.openhab.binding.myuplink.internal.MyUplinkBindingConstants.*;
import java.util.Map;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.myuplink.internal.AtomicReferenceTrait;
import org.openhab.binding.myuplink.internal.MyUplinkBindingConstants;
import org.openhab.binding.myuplink.internal.Utils;
import org.openhab.binding.myuplink.internal.command.MyUplinkCommand;
import org.openhab.binding.myuplink.internal.command.account.GetSystems;
import org.openhab.binding.myuplink.internal.command.device.GetPoints;
import org.openhab.binding.myuplink.internal.command.device.GetSmartHomeMode;
import org.openhab.binding.myuplink.internal.command.device.SetPoints;
import org.openhab.binding.myuplink.internal.command.device.SetPointsAdvanced;
import org.openhab.binding.myuplink.internal.command.device.SetSmartHomeMode;
import org.openhab.binding.myuplink.internal.config.MyUplinkConfiguration;
import org.openhab.binding.myuplink.internal.connector.CommunicationStatus;
import org.openhab.binding.myuplink.internal.provider.ChannelFactory;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.Channel;
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.ThingBuilder;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
import org.openhab.core.types.UnDefType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
/**
* The {@link MyUplinkGenericDeviceHandler} is responsible for handling commands, which are
* sent to one of the channels.
*
* @author Alexander Friese - initial contribution
*/
@NonNullByDefault
public class MyUplinkGenericDeviceHandler extends BaseThingHandler
implements MyUplinkThingHandler, DynamicChannelProvider, AtomicReferenceTrait {
private final Logger logger = LoggerFactory.getLogger(MyUplinkGenericDeviceHandler.class);
/**
* Schedule for polling live data
*/
private final AtomicReference<@Nullable Future<?>> dataPollingJobReference;
private final ChannelFactory channelFactory;
private final Configuration config;
public MyUplinkGenericDeviceHandler(Thing thing, ChannelFactory channelFactory) {
super(thing);
this.dataPollingJobReference = new AtomicReference<>(null);
this.channelFactory = channelFactory;
this.config = getConfig();
}
@Override
public void initialize() {
logger.debug("About to initialize myUplink Generic Device with id: {}", getDeviceId());
updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.NONE, STATUS_WAITING_FOR_BRIDGE);
startPolling();
}
public String getDeviceId() {
return config.get(THING_CONFIG_ID).toString();
}
private void updatePropertiesAndOnlineStatus(CommunicationStatus status, JsonObject systemsJson) {
JsonObject deviceFound = extractDevice(systemsJson);
if (deviceFound != null) {
Map<String, String> properties = editProperties();
String currentFwVersion = Utils.getAsString(deviceFound, JSON_KEY_CURRENT_FW_VERSION, GENERIC_NO_VAL);
properties.put(THING_CONFIG_CURRENT_FW_VERSION, currentFwVersion);
updateProperties(properties);
String connectionStatus = Utils.getAsString(deviceFound, JSON_KEY_CONNECTION_STATE, GENERIC_NO_VAL);
if (connectionStatus.equals(JSON_VAL_CONNECTION_CONNECTED)) {
super.updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE);
} else {
super.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, STATUS_NO_CONNECTION);
}
} else {
super.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, STATUS_DEVICE_NOT_FOUND);
}
}
private final @Nullable JsonObject extractDevice(JsonObject systemsJson) {
JsonArray systems = systemsJson.getAsJsonArray(JSON_KEY_SYSTEMS);
if (systems != null && !systems.isEmpty()) {
for (JsonElement systemJson : systems) {
JsonObject system = systemJson.getAsJsonObject();
JsonArray devices = system.getAsJsonArray(JSON_KEY_DEVICES);
if (devices != null && !devices.isEmpty()) {
for (JsonElement deviceJson : devices) {
JsonObject device = deviceJson.getAsJsonObject();
String deviceId = Utils.getAsString(device, JSON_KEY_GENERIC_ID, GENERIC_NO_VAL);
if (deviceId.equals(getDeviceId())) {
return device;
}
}
}
}
}
return null;
}
@Override
public MyUplinkCommand buildMyUplinkCommand(Command command, Channel channel) {
var deviceId = config.get(MyUplinkBindingConstants.THING_CONFIG_ID).toString();
String systemId = "";
if (config.containsKey(THING_CONFIG_SYSTEM_ID)) {
systemId = config.get(THING_CONFIG_SYSTEM_ID).toString();
}
var channelTypeId = Utils.getChannelTypeId(channel);
return switch (channelTypeId) {
case CHANNEL_TYPE_RW_COMMAND ->
new SetPointsAdvanced(this, channel, command, deviceId, this::updateOnlineStatus);
case CHANNEL_TYPE_RW_MODE -> {
if (systemId.isBlank()) {
throw new UnsupportedOperationException("systemId not configured");
}
yield new SetSmartHomeMode(this, channel, command, systemId, this::updateOnlineStatus);
}
default -> new SetPoints(this, channel, command, deviceId, this::updateOnlineStatus);
};
}
/**
* Start the polling.
*/
private void startPolling() {
updateJobReference(dataPollingJobReference, scheduler.scheduleWithFixedDelay(this::pollingRun,
POLLING_INITIAL_DELAY, getBridgeConfiguration().getDataPollingInterval(), TimeUnit.SECONDS));
}
/**
* Poll the myUplink Cloud API one time.
*/
void pollingRun() {
String deviceId = config.get(THING_CONFIG_ID).toString();
String systemId = "";
if (config.containsKey(THING_CONFIG_SYSTEM_ID)) {
systemId = config.get(THING_CONFIG_SYSTEM_ID).toString();
}
logger.debug("polling device data for {}", deviceId);
// proceed if device is online
if (getThing().getStatus() == ThingStatus.ONLINE) {
enqueueCommand(new GetPoints(this, deviceId, this::updateOnlineStatus));
if (!systemId.isBlank()) {
enqueueCommand(new GetSmartHomeMode(this, systemId, this::updateOnlineStatus));
}
}
enqueueCommand(new GetSystems(this, this::updatePropertiesAndOnlineStatus));
}
/**
* result processor to handle online status updates
*
* @param status of command execution
* @param jsonObject json respone result
*/
protected final void updateOnlineStatus(CommunicationStatus status, JsonObject jsonObject) {
String msg = Utils.getAsString(jsonObject, JSON_KEY_ERROR);
if (msg == null || msg.isBlank()) {
msg = status.getMessage();
}
switch (status.getHttpCode()) {
case OK:
case ACCEPTED:
super.updateStatus(ThingStatus.ONLINE);
break;
case BAD_REQUEST:
case UNAUTHORIZED:
case FORBIDDEN:
super.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, msg);
break;
default:
super.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, msg);
}
}
/**
* Disposes the thing.
*/
@Override
public void dispose() {
logger.debug("Handler disposing.");
cancelJobReference(dataPollingJobReference);
}
/**
* adds a channel.
*/
@Override
public void registerChannel(Channel channel) {
ThingBuilder thingBuilder = editThing();
thingBuilder.withChannel(channel);
updateThing(thingBuilder.build());
}
/**
* will update all channels provided in the map
*/
@Override
public void updateChannelStatus(Map<Channel, State> values) {
logger.debug("Handling heatpump channel update.");
for (Channel channel : values.keySet()) {
if (getThing().getChannels().contains(channel)) {
if (isLinked(channel.getUID())) {
State value = values.get(channel);
if (value != null) {
logger.debug("Channel is to be updated: {}: {}", channel.getUID().getAsString(), value);
updateState(channel.getUID(), value);
} else {
logger.debug("Value is null or not provided by myUplink Cloud (channel: {})",
channel.getUID().getAsString());
updateState(channel.getUID(), UnDefType.UNDEF);
}
}
} else {
logger.debug("Could not identify channel: {} for model {}", channel.getUID().getAsString(),
getThing().getThingTypeUID().getAsString());
}
}
}
@Override
public void enqueueCommand(MyUplinkCommand command) {
MyUplinkBridgeHandler bridgeHandler = getBridgeHandler();
if (bridgeHandler != null) {
bridgeHandler.enqueueCommand(command);
} else {
// this should not happen
logger.warn("no bridge handler found");
}
}
private @Nullable MyUplinkBridgeHandler getBridgeHandler() {
Bridge bridge = getBridge();
return bridge != null && bridge.getHandler() instanceof MyUplinkBridgeHandler handler ? handler : null;
}
@Override
public MyUplinkConfiguration getBridgeConfiguration() {
MyUplinkBridgeHandler bridgeHandler = getBridgeHandler();
return bridgeHandler == null ? new MyUplinkConfiguration() : bridgeHandler.getBridgeConfiguration();
}
@Override
public Logger getLogger() {
return logger;
}
@Override
public ThingUID getThingUid() {
return getThing().getUID();
}
@Override
public ChannelFactory getChannelFactory() {
return channelFactory;
}
}

View File

@ -0,0 +1,146 @@
/**
* 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.myuplink.internal.handler;
import static org.openhab.binding.myuplink.internal.MyUplinkBindingConstants.*;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.myuplink.internal.Utils;
import org.openhab.binding.myuplink.internal.command.MyUplinkCommand;
import org.openhab.binding.myuplink.internal.config.MyUplinkConfiguration;
import org.openhab.core.thing.Channel;
import org.openhab.core.thing.ChannelGroupUID;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingUID;
import org.openhab.core.thing.binding.ThingHandler;
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.openhab.core.types.State;
import org.slf4j.Logger;
/**
* public interface of the {@link MyUplinkThingHandler}. provides some default implementations which can be used by both
* BridgeHandlers and ThingHandlers.
*
* @author Alexander Friese - initial contribution
*/
@NonNullByDefault
public interface MyUplinkThingHandler extends ThingHandler, ChannelProvider {
/**
* just to avoid usage of static loggers.
*
* @return
*/
Logger getLogger();
/**
* method which updates the channels. needs to be implemented by thing types which have channels.
*
* @param values key-value list where key is the channel
*/
default void updateChannelStatus(Map<Channel, State> values) {
getLogger().debug("updateChannelStatus not implemented/supported by this thing type");
}
/**
* return the bridge's configuration
*
* @return
*/
MyUplinkConfiguration getBridgeConfiguration();
/**
* passes a new command o the command queue
*
* @param command to be queued/executed
*/
void enqueueCommand(MyUplinkCommand command);
/**
* default implementation to handle commands
*
* @param channelUID
* @param command
*/
@Override
default void handleCommand(ChannelUID channelUID, Command command) {
getLogger().debug("command for {}: {}", channelUID, command);
if (command instanceof RefreshType) {
return;
}
String group = channelUID.getGroupId();
group = group == null ? "" : group;
Channel channel = getChannel(group, channelUID.getIdWithoutGroup());
if (channel == null) {
// this should not happen
getLogger().warn("channel not found: {}", channelUID);
return;
}
String channelType = Utils.getChannelTypeId(channel);
if (!channelType.startsWith(CHANNEL_TYPE_PREFIX_RW)) {
getLogger().warn("channel '{}', type '{}' does not support write access - value to set '{}'",
channelUID.getIdWithoutGroup(), channelType, command);
throw new UnsupportedOperationException(
"channel (" + channelUID.getIdWithoutGroup() + ") does not support write access");
}
if (getThing().getStatus() != ThingStatus.ONLINE) {
getLogger().debug("Thing is not online, thus no commands will be handled");
return;
}
try {
enqueueCommand(buildMyUplinkCommand(command, channel));
} catch (UnsupportedOperationException e) {
getLogger().warn("Unsupported command: {}", e.getMessage());
}
}
/**
* builds the MyUplinkCommand which can be send to the webinterface.
*
* @param command the openhab raw command received from the framework
* @param channel the channel which belongs to the command.
* @return
*/
default MyUplinkCommand buildMyUplinkCommand(Command command, Channel channel) {
throw new UnsupportedOperationException("buildMyUplinkCommand not implemented/supported by this thing type");
}
/**
* determines the channel for a given groupId and channelId.
*
* @param groupId groupId of the channel
* @param channelId channelId of the channel
*/
@Override
default @Nullable Channel getChannel(String groupId, String channelId) {
ThingUID thingUID = this.getThing().getUID();
ChannelUID channelUID;
if (!groupId.isEmpty()) {
ChannelGroupUID channelGroupUID = new ChannelGroupUID(thingUID, groupId);
channelUID = new ChannelUID(channelGroupUID, channelId);
} else {
channelUID = new ChannelUID(thingUID, channelId);
}
return getThing().getChannel(channelUID);
}
}

View File

@ -0,0 +1,36 @@
/**
* 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.myuplink.internal.handler;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
/**
* functional interface to provide a function to update status of a thing or bridge.
*
* @author Alexander Friese - initial contribution
*/
@FunctionalInterface
@NonNullByDefault
public interface StatusHandler {
/**
* Called from WebInterface#authenticate() to update
* the thing status because updateStatus is protected.
*
* @param status Thing status
* @param statusDetail Thing status detail
* @param description Thing status description
*/
void updateStatusInfo(ThingStatus status, ThingStatusDetail statusDetail, String description);
}

View File

@ -0,0 +1,41 @@
/**
* 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.myuplink.internal.model;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* exception whichs is used to state a validation error
*
* @author Alexander Friese - initial contribution
*/
@NonNullByDefault
public class ConfigurationException extends RuntimeException {
private static final long serialVersionUID = 5736865225953884234L;
public ConfigurationException() {
super();
}
public ConfigurationException(String message) {
super(message);
}
public ConfigurationException(Throwable cause) {
super(cause);
}
public ConfigurationException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@ -0,0 +1,132 @@
/**
* 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.myuplink.internal.model;
import static org.openhab.binding.myuplink.internal.MyUplinkBindingConstants.*;
import java.time.format.DateTimeParseException;
import java.util.HashMap;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.myuplink.internal.Utils;
import org.openhab.binding.myuplink.internal.handler.ChannelProvider;
import org.openhab.binding.myuplink.internal.handler.DynamicChannelProvider;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.thing.Channel;
import org.openhab.core.types.State;
import org.openhab.core.types.UnDefType;
import org.openhab.core.types.util.UnitUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
/**
* transforms the http response into the openhab datamodel (instances of State)
* this is a generic trnasformer which tries to map json fields 1:1 to channels.
*
* @author Alexander Friese - initial contribution
*/
@NonNullByDefault
public class GenericResponseTransformer implements ResponseTransformer {
private final Logger logger = LoggerFactory.getLogger(GenericResponseTransformer.class);
private final ChannelProvider channelProvider;
private final @Nullable DynamicChannelProvider dynamicChannelProvider;
public GenericResponseTransformer(ChannelProvider channelProvider) {
this.channelProvider = channelProvider;
this.dynamicChannelProvider = channelProvider instanceof DynamicChannelProvider
? (DynamicChannelProvider) channelProvider
: null;
}
public Map<Channel, State> transform(JsonObject jsonData, String group) {
Map<Channel, State> result = new HashMap<>(20);
for (JsonElement channelData : Utils.getAsJsonArray(jsonData, JSON_KEY_ROOT_DATA)) {
logger.debug("received channel data: {}", channelData.toString());
var value = Utils.getAsBigDecimal(channelData.getAsJsonObject(), JSON_KEY_CHANNEL_VALUE);
var unit = UnitUtils.parseUnit(
Utils.fixUnit(Utils.getAsString(channelData.getAsJsonObject(), JSON_KEY_CHANNEL_UNIT, "")));
var channelId = Utils.getAsString(channelData.getAsJsonObject(), JSON_KEY_CHANNEL_ID, GENERIC_NO_VAL);
Channel channel;
var dcp = dynamicChannelProvider;
if (dcp != null) {
channel = getOrCreateChannel(dcp, channelId, channelData.getAsJsonObject());
} else {
channel = channelProvider.getChannel(group, channelId);
}
if (channel == null) {
logger.debug("Channel not found: {}#{}, dynamic channels not support by thing.", group, channelId);
} else {
logger.debug("mapping value '{}' to channel {}", value, channel.getUID().getId());
if (value == null) {
result.put(channel, UnDefType.NULL);
} else {
try {
var channelTypeId = Utils.getChannelTypeId(channel);
State newState;
if (channelTypeId.equals(CHANNEL_TYPE_RW_SWITCH)) {
newState = convertToOnOffType(value.stripTrailingZeros().toString());
} else if (channelTypeId.equals(CHANNEL_TYPE_RW_COMMAND)) {
newState = new StringType(value.toString());
} else if (unit != null) {
newState = new QuantityType<>(value, unit);
} else {
newState = new DecimalType(value.stripTrailingZeros());
}
if (newState == UnDefType.NULL) {
logger.warn("no mapping implemented for channel type '{}'", channelTypeId);
} else {
result.put(channel, newState);
}
} catch (NumberFormatException | DateTimeParseException ex) {
logger.warn("caught exception while parsing data for channel {} (value '{}'). Exception: {}",
channel.getUID().getId(), value, ex.getMessage());
}
}
}
}
return result;
}
private Channel getOrCreateChannel(DynamicChannelProvider dcp, String channelId, JsonObject channelData) {
var result = channelProvider.getChannel(EMPTY, channelId);
if (result == null) {
result = dcp.getChannelFactory().createChannel(dcp.getThingUid(), channelData);
dcp.registerChannel(result);
}
return result;
}
private OnOffType convertToOnOffType(String value) {
return switch (value) {
case "1" -> OnOffType.ON;
case "1.0" -> OnOffType.ON;
default -> OnOffType.OFF;
};
}
}

View File

@ -0,0 +1,40 @@
/**
* 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.myuplink.internal.model;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.thing.Channel;
import org.openhab.core.types.State;
import com.google.gson.JsonObject;
/**
* transforms the http response into the openhab datamodel (instances of State)
* this is an interface which can be implemented by different transformer classes
*
* @author Anders Alfredsson - initial contribution
*/
@NonNullByDefault
public interface ResponseTransformer {
/**
* Transform the received data into a Map of channels and the State they should be updated to
*
* @param jsonData The input json data
* @param group The channel group
* @return
*/
Map<Channel, State> transform(JsonObject jsonData, String group);
}

View File

@ -0,0 +1,66 @@
/**
* 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.myuplink.internal.model;
import static org.openhab.binding.myuplink.internal.MyUplinkBindingConstants.*;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.myuplink.internal.Utils;
import org.openhab.binding.myuplink.internal.handler.ChannelProvider;
import org.openhab.core.library.types.StringType;
import org.openhab.core.thing.Channel;
import org.openhab.core.types.State;
import org.openhab.core.types.UnDefType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.JsonObject;
/**
* transforms the http response into the openhab datamodel (instances of State)
* this is a transformer for the smart home mode data received from the api.
*
* @author Anders Alfredsson - initial contribution
*/
@NonNullByDefault
public class SmartHomeModeResponseTransformer implements ResponseTransformer {
private final Logger logger = LoggerFactory.getLogger(SmartHomeModeResponseTransformer.class);
private final ChannelProvider channelProvider;
public SmartHomeModeResponseTransformer(ChannelProvider channelProvider) {
this.channelProvider = channelProvider;
}
public Map<Channel, State> transform(JsonObject jsonData, String group) {
String mode = Utils.getAsString(jsonData, JSON_KEY_SMART_HOME_MODE);
Channel channel = channelProvider.getChannel(group, CHANNEL_ID_SMART_HOME_MODE);
if (channel == null) {
logger.warn(
"Smart home mode channel not found. This is likely because of a bug. Please report to the developers.");
return Map.of();
} else {
State newState;
if (mode == null) {
newState = UnDefType.UNDEF;
} else {
newState = new StringType(mode);
}
return Map.of(channel, newState);
}
}
}

View File

@ -0,0 +1,41 @@
/**
* 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.myuplink.internal.model;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* exception whichs is used to state a validation error
*
* @author Alexander Friese - initial contribution
*/
@NonNullByDefault
public class ValidationException extends Exception {
private static final long serialVersionUID = -6479556472780307224L;
public ValidationException() {
super();
}
public ValidationException(String message) {
super(message);
}
public ValidationException(Throwable cause) {
super(cause);
}
public ValidationException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@ -0,0 +1,237 @@
/**
* 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.myuplink.internal.provider;
import static org.openhab.binding.myuplink.internal.MyUplinkBindingConstants.*;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import javax.measure.Unit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.myuplink.internal.Utils;
import org.openhab.core.library.CoreItemFactory;
import org.openhab.core.thing.Channel;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.ThingUID;
import org.openhab.core.thing.binding.builder.ChannelBuilder;
import org.openhab.core.thing.type.ChannelTypeBuilder;
import org.openhab.core.thing.type.ChannelTypeRegistry;
import org.openhab.core.thing.type.ChannelTypeUID;
import org.openhab.core.types.StateDescriptionFragmentBuilder;
import org.openhab.core.types.StateOption;
import org.openhab.core.types.util.UnitUtils;
import org.openhab.core.util.StringUtils;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
/**
* Factory that contains logic to create dynamic channels.
*
* @author Alexander Friese - initial contribution
*/
@Component(service = { ChannelFactory.class })
@NonNullByDefault
public class ChannelFactory {
private final Logger logger = LoggerFactory.getLogger(ChannelFactory.class);
private final MyUplinkChannelTypeProvider channelTypeProvider;
private final ChannelTypeRegistry channelTypeRegistry;
@Activate
public ChannelFactory(@Reference MyUplinkChannelTypeProvider channelTypeProvider,
@Reference ChannelTypeRegistry channelTypeRegistry) {
this.channelTypeProvider = channelTypeProvider;
this.channelTypeRegistry = channelTypeRegistry;
}
public Channel createChannel(ThingUID thingUID, JsonObject channelData) {
final var channelId = Utils.getAsString(channelData, JSON_KEY_CHANNEL_ID, GENERIC_NO_VAL);
final var label = Utils.getAsString(channelData, JSON_KEY_CHANNEL_LABEL, GENERIC_NO_VAL);
final var unit = Utils.fixUnit(Utils.getAsString(channelData, JSON_KEY_CHANNEL_UNIT, ""));
final var strVal = Utils.getAsString(channelData, JSON_KEY_CHANNEL_STR_VAL, GENERIC_NO_VAL);
final var writable = Utils.getAsBool(channelData, JSON_KEY_CHANNEL_WRITABLE, Boolean.FALSE);
final var enumValues = Utils.getAsJsonArray(channelData, JSON_KEY_CHANNEL_ENUM_VALUES);
final var minValue = Utils.getAsBigDecimal(channelData, JSON_KEY_CHANNEL_MIN);
final var maxValue = Utils.getAsBigDecimal(channelData, JSON_KEY_CHANNEL_MAX);
final var stepValue = Utils.getAsBigDecimal(channelData, JSON_KEY_CHANNEL_STEP);
ChannelTypeUID channelTypeUID = null;
if (enumValues.isEmpty()) {
if (!writable) {
channelTypeUID = determineStaticChannelTypeUID(unit, strVal.contains(JSON_VAL_DECIMAL_SEPARATOR));
} else {
channelTypeUID = getOrBuildNumberChannelType(channelId, unit, minValue, maxValue, stepValue);
}
} else {
channelTypeUID = determineEnumChannelTypeUID(channelId, enumValues, writable);
}
final var channelUID = new ChannelUID(thingUID, channelId);
final var acceptedType = determineAcceptedType(channelTypeUID, unit);
final var builder = ChannelBuilder.create(channelUID).withLabel(label).withDescription(label)
.withType(channelTypeUID).withAcceptedItemType(acceptedType);
if (writable) {
var props = new HashMap<String, String>();
props.put(PARAMETER_NAME_VALIDATION_REGEXP, DEFAULT_VALIDATION_EXPRESSION);
builder.withProperties(props);
}
return builder.build();
}
String determineAcceptedType(ChannelTypeUID channelTypeUID, String unit) {
if (channelTypeUID.getId().equals(CHANNEL_TYPE_RW_SWITCH)) {
return CoreItemFactory.SWITCH;
} else if (unit.isEmpty()) {
return CoreItemFactory.NUMBER;
} else {
Unit<?> parsedUnit = UnitUtils.parseUnit(unit);
String dimension = parsedUnit == null ? null : UnitUtils.getDimensionName(parsedUnit);
if (dimension == null || dimension.isEmpty()) {
logger.warn("Could not parse unit: '{}'", unit);
return CoreItemFactory.NUMBER;
} else {
return CoreItemFactory.NUMBER + ":" + dimension;
}
}
}
private ChannelTypeUID determineStaticChannelTypeUID(String unit, boolean isDouble) {
String typeName = switch (unit) {
case CHANNEL_TYPE_ENERGY_UNIT -> CHANNEL_TYPE_ENERGY;
case CHANNEL_TYPE_PRESSURE_UNIT -> CHANNEL_TYPE_PRESSURE;
case CHANNEL_TYPE_PERCENT_UNIT -> CHANNEL_TYPE_PERCENT;
case CHANNEL_TYPE_TEMPERATURE_UNIT -> CHANNEL_TYPE_TEMPERATURE;
case CHANNEL_TYPE_FREQUENCY_UNIT -> CHANNEL_TYPE_FREQUENCY;
case CHANNEL_TYPE_FLOW_UNIT -> CHANNEL_TYPE_FLOW;
case CHANNEL_TYPE_ELECTRIC_CURRENT_UNIT -> CHANNEL_TYPE_ELECTRIC_CURRENT;
case CHANNEL_TYPE_TIME_UNIT -> CHANNEL_TYPE_TIME;
default -> isDouble ? CHANNEL_TYPE_DOUBLE : CHANNEL_TYPE_INTEGER;
};
return new ChannelTypeUID(BINDING_ID, typeName);
}
private ChannelTypeUID determineEnumChannelTypeUID(String channelId, JsonArray enumValues, boolean writable) {
var channelTypeUID = determineStaticEnumType(enumValues, writable);
if (channelTypeUID == null) {
channelTypeUID = getOrBuildDynamicEnumType(channelId, enumValues, writable);
}
return channelTypeUID;
}
private ChannelTypeUID getOrBuildDynamicEnumType(String channelId, JsonArray enumValues, boolean writable) {
final var prefix = writable ? CHANNEL_TYPE_PREFIX_RW + CHANNEL_TYPE_ENUM_PRFIX : CHANNEL_TYPE_ENUM_PRFIX;
final var channelTypeUID = new ChannelTypeUID(BINDING_ID, prefix + channelId);
var type = channelTypeRegistry.getChannelType(channelTypeUID);
if (type == null) {
var stateBuilder = StateDescriptionFragmentBuilder.create();
stateBuilder.withReadOnly(!writable).withOptions(extractEnumValues(enumValues));
var typeBuilder = ChannelTypeBuilder.state(channelTypeUID, channelId, CoreItemFactory.NUMBER)
.withStateDescriptionFragment(stateBuilder.build());
type = typeBuilder.build();
channelTypeProvider.putChannelType(type);
}
return channelTypeUID;
}
private ChannelTypeUID getOrBuildNumberChannelType(String channelId, String unit, @Nullable BigDecimal min,
@Nullable BigDecimal max, @Nullable BigDecimal step) {
final var channelTypeUID = new ChannelTypeUID(BINDING_ID,
CHANNEL_TYPE_PREFIX_RW + CHANNEL_TYPE_NUMERIC_PRFIX + channelId);
var type = channelTypeRegistry.getChannelType(channelTypeUID);
if (type == null) {
var stateBuilder = StateDescriptionFragmentBuilder.create().withReadOnly(false);
if (min != null) {
stateBuilder.withMinimum(min);
}
if (max != null) {
stateBuilder.withMaximum(max);
}
if (step != null) {
stateBuilder.withStep(step);
}
var itemType = determineAcceptedType(channelTypeUID, unit);
var typeBuilder = ChannelTypeBuilder.state(channelTypeUID, channelId, itemType)
.withStateDescriptionFragment(stateBuilder.build());
if (!itemType.equals(CoreItemFactory.NUMBER)) {
typeBuilder.withUnitHint(unit);
}
channelTypeProvider.putChannelType(typeBuilder.build());
}
return channelTypeUID;
}
List<StateOption> extractEnumValues(JsonArray enumValues) {
List<StateOption> list = new ArrayList<>();
for (var element : enumValues) {
var enumText = Utils.getAsString(element.getAsJsonObject(), JSON_ENUM_KEY_TEXT, EMPTY);
var enumOrdinal = Utils.getAsString(element.getAsJsonObject(), JSON_KEY_CHANNEL_VALUE, GENERIC_NO_VAL);
list.add(new StateOption(enumOrdinal, StringUtils.capitalizeByWhitespace(enumText.toLowerCase())));
}
return list;
}
/**
* internal method to dertermine the enum type.
*
* @param enumValues enum data from myuplink API
* @param writable flag to determine writable capability
* @return
*/
@Nullable
private ChannelTypeUID determineStaticEnumType(JsonArray enumValues, boolean writable) {
boolean containsOffAt0 = false;
boolean containsOnAt1 = false;
for (var element : enumValues) {
var enumText = Utils.getAsString(element.getAsJsonObject(), JSON_ENUM_KEY_TEXT, "").toLowerCase();
var enumOrdinal = Utils.getAsString(element.getAsJsonObject(), JSON_KEY_CHANNEL_VALUE, GENERIC_NO_VAL);
switch (enumText) {
case JSON_ENUM_VAL_OFF -> containsOffAt0 = enumOrdinal.equals(JSON_ENUM_ORD_0);
case JSON_ENUM_VAL_ON -> containsOnAt1 = enumOrdinal.equals(JSON_ENUM_ORD_1);
}
}
if (enumValues.size() == 2 && containsOffAt0 && containsOnAt1) {
if (writable) {
return new ChannelTypeUID(BINDING_ID, CHANNEL_TYPE_RW_SWITCH);
} else {
return new ChannelTypeUID(BINDING_ID, CHANNEL_TYPE_ON_OFF);
}
}
return null;
}
}

View File

@ -0,0 +1,36 @@
/**
* 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.myuplink.internal.provider;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.storage.StorageService;
import org.openhab.core.thing.binding.AbstractStorageBasedTypeProvider;
import org.openhab.core.thing.type.ChannelTypeProvider;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
/**
* Provides generated channel-types to the framework
*
* @author Alexander Friese - Initial contribution
*/
@Component(service = { ChannelTypeProvider.class, MyUplinkChannelTypeProvider.class })
@NonNullByDefault
public class MyUplinkChannelTypeProvider extends AbstractStorageBasedTypeProvider {
@Activate
public MyUplinkChannelTypeProvider(@Reference StorageService storageService) {
super(storageService);
}
}

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<addon:addon id="myuplink" 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>myUplink Binding</name>
<description>This is the binding for NIBE myUplink.</description>
<connection>cloud</connection>
</addon:addon>

View File

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<config-description:config-descriptions
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:config-description="https://openhab.org/schemas/config-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0 https://openhab.org/schemas/config-description-1.0.0.xsd">
<config-description uri="thing-type:myuplink:account">
<parameter-group name="authentication">
<label>Authentication</label>
<description>Authentication settings.</description>
</parameter-group>
<parameter-group name="connection">
<label>Connection</label>
<description>Connection settings.</description>
</parameter-group>
<parameter name="clientId" type="text" required="true" groupName="authentication">
<label>Username</label>
<description>The Client Id to login at myUplink.</description>
</parameter>
<parameter name="clientSecret" type="text" required="true" groupName="authentication">
<label>Password</label>
<context>password</context>
<description>The Client Secret to login at myUplink.</description>
</parameter>
<parameter name="dataPollingInterval" type="integer" required="false" min="30" max="3600" unit="s"
groupName="connection">
<label>Polling Interval</label>
<description>Interval in which data is polled from myUplink (in seconds).</description>
<default>60</default>
</parameter>
</config-description>
<config-description uri="thing-type:myuplink:generic-device">
<parameter name="deviceId" type="text" required="true">
<label>Device Id</label>
<description>The Id to identify the device.</description>
</parameter>
<parameter name="systemId" type="text" required="false">
<label>System Id</label>
<description>The Id of the system the device belongs to.</description>
</parameter>
</config-description>
</config-description:config-descriptions>

View File

@ -0,0 +1,80 @@
# add-on
addon.myuplink.name = myUplink Binding
addon.myuplink.description = This is the binding for NIBE myUplink.
# thing types
thing-type.myuplink.account.label = myUplink Account
thing-type.myuplink.account.description = Cloud connection to a myUplink account.
thing-type.myuplink.generic-device.label = myUplink Generic Device
thing-type.myuplink.generic-device.description = Cloud connection to a myUplink device.
thing-type.myuplink.generic-device.channel.command.label = Generic Command
thing-type.myuplink.generic-device.channel.command.description = Allows to send commands to any channel. Format 'channel:value'
thing-type.myuplink.generic-device.channel.smart-home-mode.label = Smart Home Mode
thing-type.myuplink.generic-device.channel.smart-home-mode.description = Controls the smart home mode
# thing types config
thing-type.config.myuplink.account.clientId.label = Username
thing-type.config.myuplink.account.clientId.description = The Client Id to login at myUplink.
thing-type.config.myuplink.account.clientSecret.label = Password
thing-type.config.myuplink.account.clientSecret.description = The Client Secret to login at myUplink.
thing-type.config.myuplink.account.dataPollingInterval.label = Polling Interval
thing-type.config.myuplink.account.dataPollingInterval.description = Interval in which data is polled from myUplink (in seconds).
thing-type.config.myuplink.account.group.authentication.label = Authentication
thing-type.config.myuplink.account.group.authentication.description = Authentication settings.
thing-type.config.myuplink.account.group.connection.label = Connection
thing-type.config.myuplink.account.group.connection.description = Connection settings.
thing-type.config.myuplink.generic-device.deviceId.label = Device Id
thing-type.config.myuplink.generic-device.deviceId.description = The Id to identify the device.
thing-type.config.myuplink.generic-device.systemId.label = System Id
thing-type.config.myuplink.generic-device.systemId.description = The Id of the system the device belongs to.
# channel types
channel-type.myuplink.rwtype-command.label = Generic Command
channel-type.myuplink.rwtype-mode.label = Generic Command
channel-type.myuplink.rwtype-mode.state.option.Default = Default
channel-type.myuplink.rwtype-mode.state.option.Normal = Normal
channel-type.myuplink.rwtype-mode.state.option.Away = Away
channel-type.myuplink.rwtype-mode.state.option.Vacation = Vacation
channel-type.myuplink.rwtype-mode.state.option.Home = Home
channel-type.myuplink.rwtype-mode.command.option.Default = Default
channel-type.myuplink.rwtype-mode.command.option.Normal = Normal
channel-type.myuplink.rwtype-mode.command.option.Away = Away
channel-type.myuplink.rwtype-mode.command.option.Vacation = Vacation
channel-type.myuplink.rwtype-mode.command.option.Home = Home
channel-type.myuplink.rwtype-switch.label = Generic Switch
channel-type.myuplink.type-electric-current.label = Generic Current
channel-type.myuplink.type-energy.label = Generic Energy
channel-type.myuplink.type-flow.label = Generic Flow
channel-type.myuplink.type-frequency.label = Generic Frequency
channel-type.myuplink.type-number-double.label = Generic Number (0.1)
channel-type.myuplink.type-number-integer.label = Generic Number
channel-type.myuplink.type-on-off.label = Generic OnOff
channel-type.myuplink.type-on-off.state.option.0 = Off
channel-type.myuplink.type-on-off.state.option.1 = On
channel-type.myuplink.type-percent.label = Generic Percentage
channel-type.myuplink.type-pressure.label = Generic Pressure
channel-type.myuplink.type-temperature.label = Generic Temperature
channel-type.myuplink.type-time.label = Generic Time
# thing types config
thing-type.config.myuplink.account.group.customChannels.label = Custom Channels
thing-type.config.myuplink.account.group.customChannels.description = Custom Channel configuration
thing-type.config.myuplink.account.group.general.label = General
thing-type.config.myuplink.account.group.general.description = General settings.
# status translations
status.token.validated = "Access token validated"
status.waiting.for.bridge = "Waiting for bridge to go online"
status.waiting.for.login = "Waiting for web api login"
status.no.valid.data = "No valid data received. This is most likely a configuration error"
status.no.connection = "No connection could be established"
status.device.not.found = "Device could not be found. Most likely a configuration error"
status.config.error.no.client.id = "No/Empty client id. Please fix the configuration."
status.config.error.no.client.secret = "No/Empty client secret. Please fix the configuration."

View File

@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="myuplink"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<channel-type id="type-number-integer" advanced="true">
<item-type>Number</item-type>
<label>Generic Number</label>
<state pattern="%d" readOnly="true">
</state>
</channel-type>
<channel-type id="type-number-double" advanced="true">
<item-type>Number</item-type>
<label>Generic Number (0.1)</label>
<state pattern="%.1f" readOnly="true">
</state>
</channel-type>
<channel-type id="type-temperature">
<item-type>Number:Temperature</item-type>
<label>Generic Temperature</label>
<state pattern="%.1f %unit%" readOnly="true">
</state>
</channel-type>
<channel-type id="type-flow">
<item-type>Number:VolumetricFlowRate</item-type>
<label>Generic Flow</label>
<state pattern="%.1f %unit%" readOnly="true">
</state>
</channel-type>
<channel-type id="type-electric-current" advanced="true">
<item-type>Number:ElectricCurrent</item-type>
<label>Generic Current</label>
<state pattern="%.1f %unit%" readOnly="true">
</state>
</channel-type>
<channel-type id="type-time">
<item-type>Number:Time</item-type>
<label>Generic Time</label>
<state pattern="%d %unit%" readOnly="true">
</state>
</channel-type>
<channel-type id="type-frequency">
<item-type>Number:Frequency</item-type>
<label>Generic Frequency</label>
<state pattern="%d %unit%" readOnly="true">
</state>
</channel-type>
<channel-type id="type-energy">
<item-type>Number:Energy</item-type>
<label>Generic Energy</label>
<state pattern="%.1f %unit%" readOnly="true">
</state>
</channel-type>
<channel-type id="type-pressure" advanced="true">
<item-type>Number:Pressure</item-type>
<label>Generic Pressure</label>
<state pattern="%.1f %unit%" readOnly="true">
</state>
</channel-type>
<channel-type id="type-percent">
<item-type>Number:Dimensionless</item-type>
<label>Generic Percentage</label>
<state pattern="%d %%" readOnly="true">
</state>
</channel-type>
<channel-type id="type-on-off">
<item-type>Number</item-type>
<label>Generic OnOff</label>
<state readOnly="true">
<options>
<option value="0">Off</option>
<option value="1">On</option>
</options>
</state>
</channel-type>
</thing:thing-descriptions>

View File

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="myuplink"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<channel-type id="rwtype-switch">
<item-type>Switch</item-type>
<label>Generic Switch</label>
<state readOnly="false"></state>
</channel-type>
<channel-type id="rwtype-command" advanced="true">
<item-type>String</item-type>
<label>Generic Command</label>
<state readOnly="false"></state>
</channel-type>
<channel-type id="rwtype-mode">
<item-type>String</item-type>
<label>Generic Command</label>
<state readOnly="false">
<options>
<option value="Default">Default</option>
<option value="Normal">Normal</option>
<option value="Away">Away</option>
<option value="Vacation">Vacation</option>
<option value="Home">Home</option>
</options>
</state>
<command>
<options>
<option value="Default">Default</option>
<option value="Normal">Normal</option>
<option value="Away">Away</option>
<option value="Vacation">Vacation</option>
<option value="Home">Home</option>
</options>
</command>
</channel-type>
</thing:thing-descriptions>

View File

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="myuplink"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<bridge-type id="account">
<label>myUplink Account</label>
<description>Cloud connection to a myUplink account.</description>
<config-description-ref uri="thing-type:myuplink:account"/>
</bridge-type>
<thing-type id="generic-device">
<supported-bridge-type-refs>
<bridge-type-ref id="account"/>
</supported-bridge-type-refs>
<label>myUplink Generic Device</label>
<description>Cloud connection to a myUplink device.</description>
<channels>
<channel id="command" typeId="rwtype-command">
<label>Generic Command</label>
<description>Allows to send commands to any channel. Format 'channel:value'</description>
<properties>
<property name="validationExpression">[0-9]+:-?[0-9,]+</property>
</properties>
</channel>
<channel id="smart-home-mode" typeId="rwtype-mode">
<label>Smart Home Mode</label>
<description>Controls the smart home mode</description>
<properties>
<property name="validationExpression">\w+</property>
</properties>
</channel>
</channels>
<config-description-ref uri="thing-type:myuplink:generic-device"/>
</thing-type>
</thing:thing-descriptions>

View File

@ -0,0 +1,135 @@
/**
* 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.myuplink.internal.discovery;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.openhab.binding.myuplink.internal.MyUplinkBindingConstants.*;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import org.openhab.binding.myuplink.internal.connector.CommunicationStatus;
import org.openhab.binding.myuplink.internal.handler.MyUplinkAccountHandler;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.ThingUID;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
/**
* Unit Tests to verify behaviour of DiscoveryService implementation.
*
* @author Alexander Friese - initial contribution
*/
@NonNullByDefault
@ExtendWith(MockitoExtension.class)
public class MyUplinkDiscoveryServiceTest {
private MyUplinkAccountHandler bridgeHandler = mock(MyUplinkAccountHandler.class);
private CommunicationStatus communicationStatus = mock(CommunicationStatus.class);
private MyUplinkDiscoveryService discoveryService = spy(MyUplinkDiscoveryService.class);
private final String emptyResponseString = """
{"page":1,"itemsPerPage":100,"numItems":0,"systems":[]}
""";
private JsonObject emptyResponse = new JsonObject();
private final String testResponseString = """
{
"page": 0,
"itemsPerPage": 0,
"numItems": 0,
"systems": [
{
"systemId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "string",
"securityLevel": "admin",
"hasAlarm": true,
"country": "string",
"devices": [
{
"id": "Dev-1337",
"connectionState": "Disconnected",
"currentFwVersion": "string",
"product": {
"serialNumber": "1337",
"name": "My Device 1337"
}
},
{
"id": "Dev-4712",
"connectionState": "Disconnected",
"currentFwVersion": "string",
"product": {
"serialNumber": "4712",
"name": "My Device 4712"
}
}
]
}
]
}
""";
private static JsonObject testResponse = new JsonObject();
@BeforeEach
public void prepareTestData() {
emptyResponse = JsonParser.parseString(emptyResponseString).getAsJsonObject();
testResponse = JsonParser.parseString(testResponseString).getAsJsonObject();
discoveryService.setThingHandler(bridgeHandler);
}
@Test
public void testEmptyResponse() {
discoveryService.processMyUplinkDiscoveryResult(communicationStatus, emptyResponse);
// testdata contains no systems -> no further processing
verify(discoveryService, never()).handleSystemDiscovery(any());
verify(discoveryService, never()).handleDeviceDiscovery(any(), any());
verify(discoveryService, never()).initDiscoveryResultBuilder(any(), any(), any());
}
@Test
public void testSampleResponse() {
// mocking of bridgehandler needed to get an UID.
Bridge mockThing = mock(Bridge.class);
when(mockThing.getUID()).thenReturn(new ThingUID(THING_TYPE_ACCOUNT, "testAccount4711"));
when(bridgeHandler.getThing()).thenReturn(mockThing);
discoveryService.processMyUplinkDiscoveryResult(communicationStatus, testResponse);
// testdata contains one system
verify(discoveryService, times(1)).handleSystemDiscovery(any());
// testdata contains two devices
verify(discoveryService, times(2)).handleDeviceDiscovery(any(), any());
// builder should be called once for each device
verify(discoveryService, times(2)).initDiscoveryResultBuilder(any(), any(), any());
// verify that correct values are extracted from data
verify(discoveryService).initDiscoveryResultBuilder(DEVICE_GENERIC_DEVICE, "Dev-4712", "My Device 4712");
verify(discoveryService).initDiscoveryResultBuilder(DEVICE_GENERIC_DEVICE, "Dev-1337", "My Device 1337");
}
}

View File

@ -0,0 +1,205 @@
/**
* 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.myuplink.internal.provider;
import static org.hamcrest.CoreMatchers.*;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.openhab.binding.myuplink.internal.MyUplinkBindingConstants.*;
import java.util.Objects;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.Test;
import org.openhab.binding.myuplink.internal.MyUplinkBindingConstants;
import org.openhab.binding.myuplink.internal.Utils;
import org.openhab.core.library.CoreItemFactory;
import org.openhab.core.test.storage.VolatileStorageService;
import org.openhab.core.thing.ThingUID;
import org.openhab.core.thing.type.ChannelTypeRegistry;
import org.openhab.core.thing.type.ChannelTypeUID;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
/**
* Unit Tests to verify behaviour of ChannelFactory implementation.
*
* @author Alexander Friese - initial contribution
*/
@NonNullByDefault
public class ChannelFactoryTest {
private final MyUplinkChannelTypeProvider channelTypeProvider = new MyUplinkChannelTypeProvider(
new VolatileStorageService());
private final ChannelFactory channelFactory = new ChannelFactory(channelTypeProvider, new ChannelTypeRegistry());
private static final ThingUID TEST_THING_UID = new ThingUID(MyUplinkBindingConstants.BINDING_ID, "genericThing",
"myUnit");
private final String testChannelDataTemperature = """
{"category":"NIBEF VVM 320 E","parameterId":"40121","parameterName":"Add. heat (BT63)","parameterUnit":"°C","writable":false,"timestamp":"2024-05-10T05:35:50+00:00","value":39.0,"strVal":"39°C","smartHomeCategories":[],"minValue":null,"maxValue":null,"stepValue":1.0,"enumValues":[],"scaleValue":"0.1","zoneId":null}
""";
private final String testChannelEnumWritableSwitch = """
{"category":"NIBEF VVM 320 E","parameterId":"50004","parameterName":"Temporary lux","parameterUnit":"","writable":true,"timestamp":"2024-05-05T13:41:09+00:00","value":0.0,"strVal":"off","smartHomeCategories":[],"minValue":null,"maxValue":null,"stepValue":1.0,"enumValues":[{"value":"0","text":"off","icon":""},{"value":"1","text":"on","icon":""}],"scaleValue":"1","zoneId":null}
""";
private final String testChannelEnumSwitch = """
{"category":"NIBEF VVM 320 E","parameterId":"49992","parameterName":"Pump: Heating medium (GP6)","parameterUnit":"","writable":false,"timestamp":"2024-05-05T13:41:09+00:00","value":0.0,"strVal":"Off","smartHomeCategories":[],"minValue":null,"maxValue":null,"stepValue":1.0,"enumValues":[{"value":"0","text":"Off","icon":""},{"value":"1","text":"On","icon":""}],"scaleValue":"1","zoneId":null}
""";
private final String testChannelEnumPriority = """
{"category":"NIBEF VVM 320 E","parameterId":"49994","parameterName":"Priority","parameterUnit":"","writable":false,"timestamp":"2024-05-10T03:31:23+00:00","value":10.0,"strVal":"Off","smartHomeCategories":[],"minValue":null,"maxValue":null,"stepValue":1.0,"enumValues":[{"value":"10","text":"Off","icon":""},{"value":"20","text":"Hot water","icon":""},{"value":"30","text":"Heating","icon":""},{"value":"40","text":"Pool","icon":""},{"value":"41","text":"Pool 2","icon":""},{"value":"50","text":"Trans­fer","icon":""},{"value":"60","text":"Cooling","icon":""}],"scaleValue":"1","zoneId":null}
""";
private final String testChannelEnumCompressorStatus = """
{"category":"Slave 1 (EB101)","parameterId":"44064","parameterName":"Status compressor (EB101)","parameterUnit":"","writable":false,"timestamp":"2024-05-10T03:31:28+00:00","value":20.0,"strVal":"off","smartHomeCategories":[],"minValue":null,"maxValue":null,"stepValue":1.0,"enumValues":[{"value":"20","text":"off","icon":""},{"value":"40","text":"starts","icon":""},{"value":"60","text":"runs","icon":""},{"value":"100","text":"stops","icon":""}],"scaleValue":"1","zoneId":null}
""";
private final String testChannelEnumAddHeatStatus = """
{"category":"NIBEF VVM 320 E","parameterId":"49993","parameterName":"Int elec add heat","parameterUnit":"","writable":false,"timestamp":"2024-05-05T13:41:27+00:00","value":4.0,"strVal":"Blocked","smartHomeCategories":[],"minValue":null,"maxValue":null,"stepValue":1.0,"enumValues":[{"value":"0","text":"Alarm","icon":""},{"value":"1","text":"Alarm","icon":""},{"value":"2","text":"Active","icon":""},{"value":"3","text":"Off","icon":""},{"value":"4","text":"Blocked","icon":""},{"value":"5","text":"Off","icon":""},{"value":"6","text":"Active","icon":""}],"scaleValue":"1","zoneId":null}
""";
private final String testChannelEnumHeatPumpStatusWithLowerCaseTexts = """
{ "category": "Heat pump 1", "parameterId": "62017", "parameterName": "Status", "parameterUnit": "", "writable": false, "timestamp": "2024-05-21T16:22:21+00:00", "value": 1, "strVal": "Off, ready to start", "smartHomeCategories": [], "minValue": null, "maxValue": null, "stepValue": 1, "enumValues": [ { "value": "0", "text": "Off, start delay", "icon": "" }, { "value": "1", "text": "OFF, ready to start", "icon": "" }, { "value": "2", "text": "Wait until flow", "icon": "" }, { "value": "3", "text": "On", "icon": "" }, { "value": "4", "text": "Defrost", "icon": "" }, { "value": "5", "text": "Cooling", "icon": "" }, { "value": "6", "text": "Blocked", "icon": "" }, { "value": "7", "text": "Off, alarm", "icon": "" }, { "value": "8", "text": "Function test", "icon": "" }, { "value": "30", "text": "not defined", "icon": "" }, { "value": "31", "text": "Comp. disabled", "icon": "" }, { "value": "32", "text": "Comm. error", "icon": "" }, { "value": "33", "text": "Hot Water", "icon": "" } ], "scaleValue": "1", "zoneId": null }
""";
@Test
public void testFromJsonDataTemperature() {
var gson = new Gson();
var json = gson.fromJson(testChannelDataTemperature, JsonObject.class);
json = json == null ? new JsonObject() : json;
var result = channelFactory.createChannel(TEST_THING_UID, json);
assertThat(result.getAcceptedItemType(), is("Number:Temperature"));
assertThat(Objects.requireNonNull(result.getChannelTypeUID()).getId(), is("type-temperature"));
assertThat(result.getUID().getThingUID(), is(TEST_THING_UID));
assertThat(result.getUID().getId(), is("40121"));
assertThat(result.getDescription(), is("Add. heat (BT63)"));
assertThat(result.getLabel(), is("Add. heat (BT63)"));
}
@Test
public void testFromJsonDataEnumWritableSwitch() {
var gson = new Gson();
var json = gson.fromJson(testChannelEnumWritableSwitch, JsonObject.class);
json = json == null ? new JsonObject() : json;
var result = channelFactory.createChannel(TEST_THING_UID, json);
assertThat(result.getAcceptedItemType(), is(CoreItemFactory.SWITCH));
assertThat(Objects.requireNonNull(result.getChannelTypeUID()).getId(), is("rwtype-switch"));
assertThat(result.getUID().getThingUID(), is(TEST_THING_UID));
assertThat(result.getUID().getId(), is("50004"));
assertThat(result.getDescription(), is("Temporary lux"));
assertThat(result.getLabel(), is("Temporary lux"));
}
@Test
public void testFromJsonDataEnumSwitch() {
var gson = new Gson();
var json = gson.fromJson(testChannelEnumSwitch, JsonObject.class);
json = json == null ? new JsonObject() : json;
var result = channelFactory.createChannel(TEST_THING_UID, json);
assertThat(result.getAcceptedItemType(), is(CoreItemFactory.NUMBER));
assertThat(Objects.requireNonNull(result.getChannelTypeUID()).getId(), is("type-on-off"));
assertThat(result.getUID().getThingUID(), is(TEST_THING_UID));
assertThat(result.getUID().getId(), is("49992"));
assertThat(result.getDescription(), is("Pump: Heating medium (GP6)"));
assertThat(result.getLabel(), is("Pump: Heating medium (GP6)"));
}
@Test
public void testFromJsonDataEnumPriority() {
var gson = new Gson();
var json = gson.fromJson(testChannelEnumPriority, JsonObject.class);
json = json == null ? new JsonObject() : json;
var result = channelFactory.createChannel(TEST_THING_UID, json);
assertThat(result.getAcceptedItemType(), is(CoreItemFactory.NUMBER));
assertThat(Objects.requireNonNull(result.getChannelTypeUID()).getId(), is("type-enum-49994"));
}
@Test
public void testFromJsonDataEnumCompressorStatus() {
var gson = new Gson();
var json = gson.fromJson(testChannelEnumCompressorStatus, JsonObject.class);
json = json == null ? new JsonObject() : json;
var result = channelFactory.createChannel(TEST_THING_UID, json);
assertThat(result.getAcceptedItemType(), is(CoreItemFactory.NUMBER));
assertThat(Objects.requireNonNull(result.getChannelTypeUID()).getId(), is("type-enum-44064"));
}
@Test
public void testFromJsonDataEnumAddHeatStatus() {
var gson = new Gson();
var json = gson.fromJson(testChannelEnumAddHeatStatus, JsonObject.class);
json = json == null ? new JsonObject() : json;
var result = channelFactory.createChannel(TEST_THING_UID, json);
assertThat(result.getAcceptedItemType(), is(CoreItemFactory.NUMBER));
assertThat(Objects.requireNonNull(result.getChannelTypeUID()).getId(), is("type-enum-49993"));
var type = channelTypeProvider.getChannelType(new ChannelTypeUID(BINDING_ID, "type-enum-49993"), null);
assertNotNull(type);
assertThat(Objects.requireNonNull(type.getState()).getOptions().size(), is(7));
}
@Test
public void testFromJsonDataEnumHeatPumpStatus() {
var gson = new Gson();
var json = gson.fromJson(testChannelEnumHeatPumpStatusWithLowerCaseTexts, JsonObject.class);
json = json == null ? new JsonObject() : json;
var result = channelFactory.createChannel(TEST_THING_UID, json);
assertThat(result.getAcceptedItemType(), is(CoreItemFactory.NUMBER));
assertThat(Objects.requireNonNull(result.getChannelTypeUID()).getId(), is("type-enum-62017"));
var type = channelTypeProvider.getChannelType(new ChannelTypeUID(BINDING_ID, "type-enum-62017"), null);
assertNotNull(type);
assertThat(Objects.requireNonNull(type.getState()).getOptions().size(), is(13));
}
@Test
public void testHeatPumpStatusEnumValues() {
var gson = new Gson();
var json = gson.fromJson(testChannelEnumHeatPumpStatusWithLowerCaseTexts, JsonObject.class);
json = json == null ? new JsonObject() : json;
var enumValues = Utils.getAsJsonArray(json, JSON_KEY_CHANNEL_ENUM_VALUES);
var list = channelFactory.extractEnumValues(enumValues);
assertThat(list.size(), is(13));
list.forEach(enumMapping -> {
switch (enumMapping.getValue()) {
case "0" -> assertThat(enumMapping.getLabel(), is("Off, Start Delay"));
case "1" -> assertThat(enumMapping.getLabel(), is("Off, Ready To Start"));
case "2" -> assertThat(enumMapping.getLabel(), is("Wait Until Flow"));
case "3" -> assertThat(enumMapping.getLabel(), is("On"));
case "4" -> assertThat(enumMapping.getLabel(), is("Defrost"));
case "5" -> assertThat(enumMapping.getLabel(), is("Cooling"));
case "6" -> assertThat(enumMapping.getLabel(), is("Blocked"));
case "7" -> assertThat(enumMapping.getLabel(), is("Off, Alarm"));
case "8" -> assertThat(enumMapping.getLabel(), is("Function Test"));
case "30" -> assertThat(enumMapping.getLabel(), is("Not Defined"));
case "31" -> assertThat(enumMapping.getLabel(), is("Comp. Disabled"));
case "32" -> assertThat(enumMapping.getLabel(), is("Comm. Error"));
case "33" -> assertThat(enumMapping.getLabel(), is("Hot Water"));
default -> assertNotNull(null, "unknown enum value");
}
});
}
}

View File

@ -285,6 +285,7 @@
<module>org.openhab.binding.mycroft</module>
<module>org.openhab.binding.mynice</module>
<module>org.openhab.binding.mystrom</module>
<module>org.openhab.binding.myuplink</module>
<module>org.openhab.binding.nanoleaf</module>
<module>org.openhab.binding.neato</module>
<module>org.openhab.binding.neeo</module>