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