diff --git a/CODEOWNERS b/CODEOWNERS index d2acdc2d780..c8e6a7be309 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml index 358238e10e5..d2496ca8f27 100644 --- a/bom/openhab-addons/pom.xml +++ b/bom/openhab-addons/pom.xml @@ -1236,6 +1236,11 @@ org.openhab.binding.mystrom ${project.version} + + org.openhab.addons.bundles + org.openhab.binding.myuplink + ${project.version} + org.openhab.addons.bundles org.openhab.binding.nanoleaf diff --git a/bundles/org.openhab.binding.myuplink/NOTICE b/bundles/org.openhab.binding.myuplink/NOTICE new file mode 100644 index 00000000000..edfd204e5d2 --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/NOTICE @@ -0,0 +1,13 @@ +This content is produced and maintained by the openHAB project. + +* Project home: https://www.openhab.org + +== Declared Project Licenses + +This program and the accompanying materials are made available under the terms +of the Eclipse Public License 2.0 which is available at +https://www.eclipse.org/legal/epl-2.0/. + +== Source Code + +https://github.com/openhab/openhab-addons diff --git a/bundles/org.openhab.binding.myuplink/README.md b/bundles/org.openhab.binding.myuplink/README.md new file mode 100644 index 00000000000..98f6fa7c04b --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/README.md @@ -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 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 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" } +``` diff --git a/bundles/org.openhab.binding.myuplink/pom.xml b/bundles/org.openhab.binding.myuplink/pom.xml new file mode 100644 index 00000000000..7ff5b539382 --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/pom.xml @@ -0,0 +1,17 @@ + + + + 4.0.0 + + + org.openhab.addons.bundles + org.openhab.addons.reactor.bundles + 4.3.0-SNAPSHOT + + + org.openhab.binding.myuplink + + openHAB Add-ons :: Bundles :: myUplink Binding + + diff --git a/bundles/org.openhab.binding.myuplink/src/main/feature/feature.xml b/bundles/org.openhab.binding.myuplink/src/main/feature/feature.xml new file mode 100644 index 00000000000..753c27ef2ce --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/src/main/feature/feature.xml @@ -0,0 +1,9 @@ + + + mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features + + + openhab-runtime-base + mvn:org.openhab.addons.bundles/org.openhab.binding.myuplink/${project.version} + + diff --git a/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/AtomicReferenceTrait.java b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/AtomicReferenceTrait.java new file mode 100644 index 00000000000..1a7a9a10798 --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/AtomicReferenceTrait.java @@ -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)); + } +} diff --git a/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/MyUplinkBindingConstants.java b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/MyUplinkBindingConstants.java new file mode 100644 index 00000000000..6f4ac2b24bc --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/MyUplinkBindingConstants.java @@ -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 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]+"; +} diff --git a/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/MyUplinkHandlerFactory.java b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/MyUplinkHandlerFactory.java new file mode 100644 index 00000000000..c9bbdd0fe84 --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/MyUplinkHandlerFactory.java @@ -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; + } +} diff --git a/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/Utils.java b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/Utils.java new file mode 100644 index 00000000000..cbcac58dbf8 --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/Utils.java @@ -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; + }; + } +} diff --git a/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/command/AbstractCommand.java b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/command/AbstractCommand.java new file mode 100644 index 00000000000..30bf10d2dc8 --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/command/AbstractCommand.java @@ -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(); +} diff --git a/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/command/AbstractPagingCommand.java b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/command/AbstractPagingCommand.java new file mode 100644 index 00000000000..4a7a8ec24f8 --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/command/AbstractPagingCommand.java @@ -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; + } +} diff --git a/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/command/AbstractWriteCommand.java b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/command/AbstractWriteCommand.java new file mode 100644 index 00000000000..0632bf0c1fa --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/command/AbstractWriteCommand.java @@ -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 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; +} diff --git a/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/command/JsonResultProcessor.java b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/command/JsonResultProcessor.java new file mode 100644 index 00000000000..ccf51e7e39c --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/command/JsonResultProcessor.java @@ -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); +} diff --git a/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/command/MyUplinkCommand.java b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/command/MyUplinkCommand.java new file mode 100644 index 00000000000..17198266223 --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/command/MyUplinkCommand.java @@ -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; +} diff --git a/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/command/account/GetSystems.java b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/command/account/GetSystems.java new file mode 100644 index 00000000000..7cff5d5541d --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/command/account/GetSystems.java @@ -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; + } +} diff --git a/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/command/account/Login.java b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/command/account/Login.java new file mode 100644 index 00000000000..e205e5c4c42 --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/command/account/Login.java @@ -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; + } +} diff --git a/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/command/device/GetPoints.java b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/command/device/GetPoints.java new file mode 100644 index 00000000000..e1dc7562d35 --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/command/device/GetPoints.java @@ -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; + } +} diff --git a/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/command/device/GetSmartHomeMode.java b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/command/device/GetSmartHomeMode.java new file mode 100644 index 00000000000..46c716f83f7 --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/command/device/GetSmartHomeMode.java @@ -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; + } +} diff --git a/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/command/device/SetPoints.java b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/command/device/SetPoints.java new file mode 100644 index 00000000000..16ca6bc013d --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/command/device/SetPoints.java @@ -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; + } +} diff --git a/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/command/device/SetPointsAdvanced.java b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/command/device/SetPointsAdvanced.java new file mode 100644 index 00000000000..4bee2688f0c --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/command/device/SetPointsAdvanced.java @@ -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 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)); + } +} diff --git a/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/command/device/SetSmartHomeMode.java b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/command/device/SetSmartHomeMode.java new file mode 100644 index 00000000000..65732fc13fa --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/command/device/SetSmartHomeMode.java @@ -0,0 +1,65 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.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; + } +} diff --git a/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/config/MyUplinkConfiguration.java b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/config/MyUplinkConfiguration.java new file mode 100644 index 00000000000..8f8ce78c01e --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/config/MyUplinkConfiguration.java @@ -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(); + } +} diff --git a/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/connector/CommunicationStatus.java b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/connector/CommunicationStatus.java new file mode 100644 index 00000000000..4e95a4b813b --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/connector/CommunicationStatus.java @@ -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 ""; + } +} diff --git a/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/connector/WebInterface.java b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/connector/WebInterface.java new file mode 100644 index 00000000000..23c7c7fc3dc --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/connector/WebInterface.java @@ -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 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; + } +} diff --git a/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/discovery/MyUplinkDiscoveryService.java b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/discovery/MyUplinkDiscoveryService.java new file mode 100644 index 00000000000..f2c2bd8f793 --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/discovery/MyUplinkDiscoveryService.java @@ -0,0 +1,144 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.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); + } +} diff --git a/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/handler/ChannelProvider.java b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/handler/ChannelProvider.java new file mode 100644 index 00000000000..6b1c9b52885 --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/handler/ChannelProvider.java @@ -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); +} diff --git a/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/handler/DynamicChannelProvider.java b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/handler/DynamicChannelProvider.java new file mode 100644 index 00000000000..4e07427d6c8 --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/handler/DynamicChannelProvider.java @@ -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(); +} diff --git a/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/handler/MyUplinkAccountHandler.java b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/handler/MyUplinkAccountHandler.java new file mode 100644 index 00000000000..2b6c0192065 --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/handler/MyUplinkAccountHandler.java @@ -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> 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; + } +} diff --git a/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/handler/MyUplinkBridgeHandler.java b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/handler/MyUplinkBridgeHandler.java new file mode 100644 index 00000000000..34a2cf8c2f5 --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/handler/MyUplinkBridgeHandler.java @@ -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(); +} diff --git a/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/handler/MyUplinkGenericDeviceHandler.java b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/handler/MyUplinkGenericDeviceHandler.java new file mode 100644 index 00000000000..a30a2b7f1a2 --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/handler/MyUplinkGenericDeviceHandler.java @@ -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 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 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; + } +} diff --git a/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/handler/MyUplinkThingHandler.java b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/handler/MyUplinkThingHandler.java new file mode 100644 index 00000000000..88d0c4ed790 --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/handler/MyUplinkThingHandler.java @@ -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 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); + } +} diff --git a/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/handler/StatusHandler.java b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/handler/StatusHandler.java new file mode 100644 index 00000000000..a3841897f41 --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/handler/StatusHandler.java @@ -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); +} diff --git a/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/model/ConfigurationException.java b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/model/ConfigurationException.java new file mode 100644 index 00000000000..95a0abcf60f --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/model/ConfigurationException.java @@ -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); + } +} diff --git a/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/model/GenericResponseTransformer.java b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/model/GenericResponseTransformer.java new file mode 100644 index 00000000000..c0d6f79f99e --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/model/GenericResponseTransformer.java @@ -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 transform(JsonObject jsonData, String group) { + Map 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; + }; + } +} diff --git a/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/model/ResponseTransformer.java b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/model/ResponseTransformer.java new file mode 100644 index 00000000000..fecac3eb5c2 --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/model/ResponseTransformer.java @@ -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 transform(JsonObject jsonData, String group); +} diff --git a/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/model/SmartHomeModeResponseTransformer.java b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/model/SmartHomeModeResponseTransformer.java new file mode 100644 index 00000000000..10281ec3171 --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/model/SmartHomeModeResponseTransformer.java @@ -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 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); + } + } +} diff --git a/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/model/ValidationException.java b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/model/ValidationException.java new file mode 100644 index 00000000000..019f5ccfca8 --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/model/ValidationException.java @@ -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); + } +} diff --git a/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/provider/ChannelFactory.java b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/provider/ChannelFactory.java new file mode 100644 index 00000000000..7ec39e78e4c --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/provider/ChannelFactory.java @@ -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(); + 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 extractEnumValues(JsonArray enumValues) { + List 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; + } +} diff --git a/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/provider/MyUplinkChannelTypeProvider.java b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/provider/MyUplinkChannelTypeProvider.java new file mode 100644 index 00000000000..db3604267a7 --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/src/main/java/org/openhab/binding/myuplink/internal/provider/MyUplinkChannelTypeProvider.java @@ -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); + } +} diff --git a/bundles/org.openhab.binding.myuplink/src/main/resources/OH-INF/addon/addon.xml b/bundles/org.openhab.binding.myuplink/src/main/resources/OH-INF/addon/addon.xml new file mode 100644 index 00000000000..43e9cbdafdb --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/src/main/resources/OH-INF/addon/addon.xml @@ -0,0 +1,9 @@ + + + binding + myUplink Binding + This is the binding for NIBE myUplink. + cloud + diff --git a/bundles/org.openhab.binding.myuplink/src/main/resources/OH-INF/config/config.xml b/bundles/org.openhab.binding.myuplink/src/main/resources/OH-INF/config/config.xml new file mode 100644 index 00000000000..cb50a4c74be --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/src/main/resources/OH-INF/config/config.xml @@ -0,0 +1,45 @@ + + + + + + + Authentication settings. + + + + Connection settings. + + + + + The Client Id to login at myUplink. + + + + password + The Client Secret to login at myUplink. + + + + Interval in which data is polled from myUplink (in seconds). + 60 + + + + + + + The Id to identify the device. + + + + The Id of the system the device belongs to. + + + + diff --git a/bundles/org.openhab.binding.myuplink/src/main/resources/OH-INF/i18n/myuplink.properties b/bundles/org.openhab.binding.myuplink/src/main/resources/OH-INF/i18n/myuplink.properties new file mode 100644 index 00000000000..e1b581d892e --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/src/main/resources/OH-INF/i18n/myuplink.properties @@ -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." diff --git a/bundles/org.openhab.binding.myuplink/src/main/resources/OH-INF/thing/readonly-channel-types.xml b/bundles/org.openhab.binding.myuplink/src/main/resources/OH-INF/thing/readonly-channel-types.xml new file mode 100644 index 00000000000..0ad15c7512a --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/src/main/resources/OH-INF/thing/readonly-channel-types.xml @@ -0,0 +1,77 @@ + + + + + Number + + + + + + Number + + + + + + Number:Temperature + + + + + + Number:VolumetricFlowRate + + + + + + Number:ElectricCurrent + + + + + + Number:Time + + + + + + Number:Frequency + + + + + + Number:Energy + + + + + + Number:Pressure + + + + + + Number:Dimensionless + + + + + + Number + + + + + + + + + diff --git a/bundles/org.openhab.binding.myuplink/src/main/resources/OH-INF/thing/readwrite-channel-types.xml b/bundles/org.openhab.binding.myuplink/src/main/resources/OH-INF/thing/readwrite-channel-types.xml new file mode 100644 index 00000000000..df46da34c09 --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/src/main/resources/OH-INF/thing/readwrite-channel-types.xml @@ -0,0 +1,39 @@ + + + + + Switch + + + + + String + + + + + String + + + + + + + + + + + + + + + + + + + + + diff --git a/bundles/org.openhab.binding.myuplink/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.myuplink/src/main/resources/OH-INF/thing/thing-types.xml new file mode 100644 index 00000000000..796d2bafc25 --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/src/main/resources/OH-INF/thing/thing-types.xml @@ -0,0 +1,35 @@ + + + + + Cloud connection to a myUplink account. + + + + + + + + Cloud connection to a myUplink device. + + + + Allows to send commands to any channel. Format 'channel:value' + + [0-9]+:-?[0-9,]+ + + + + + Controls the smart home mode + + \w+ + + + + + + diff --git a/bundles/org.openhab.binding.myuplink/src/test/java/org/openhab/binding/myuplink/internal/discovery/MyUplinkDiscoveryServiceTest.java b/bundles/org.openhab.binding.myuplink/src/test/java/org/openhab/binding/myuplink/internal/discovery/MyUplinkDiscoveryServiceTest.java new file mode 100644 index 00000000000..761eb5ff298 --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/src/test/java/org/openhab/binding/myuplink/internal/discovery/MyUplinkDiscoveryServiceTest.java @@ -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"); + } +} diff --git a/bundles/org.openhab.binding.myuplink/src/test/java/org/openhab/binding/myuplink/internal/provider/ChannelFactoryTest.java b/bundles/org.openhab.binding.myuplink/src/test/java/org/openhab/binding/myuplink/internal/provider/ChannelFactoryTest.java new file mode 100644 index 00000000000..f6d6a5faa47 --- /dev/null +++ b/bundles/org.openhab.binding.myuplink/src/test/java/org/openhab/binding/myuplink/internal/provider/ChannelFactoryTest.java @@ -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"); + } + }); + } +} diff --git a/bundles/pom.xml b/bundles/pom.xml index 8820a5989e0..61ebb03ef13 100644 --- a/bundles/pom.xml +++ b/bundles/pom.xml @@ -285,6 +285,7 @@ org.openhab.binding.mycroft org.openhab.binding.mynice org.openhab.binding.mystrom + org.openhab.binding.myuplink org.openhab.binding.nanoleaf org.openhab.binding.neato org.openhab.binding.neeo