diff --git a/CODEOWNERS b/CODEOWNERS
index 2846ddb7dbfb3..47a7cddad15ed 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -46,6 +46,7 @@
/bundles/org.openhab.binding.dlinksmarthome/ @MikeJMajor
/bundles/org.openhab.binding.dmx/ @J-N-K
/bundles/org.openhab.binding.doorbird/ @mhilbush
+/bundles/org.openhab.binding.draytonwiser/ @andrew-schofield
/bundles/org.openhab.binding.dscalarm/ @RSStephens
/bundles/org.openhab.binding.dsmr/ @Hilbrand
/bundles/org.openhab.binding.ecobee/ @mhilbush
diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml
index d7a5c0a1ee627..01e822fda2e4f 100644
--- a/bom/openhab-addons/pom.xml
+++ b/bom/openhab-addons/pom.xml
@@ -219,6 +219,11 @@
org.openhab.binding.doorbird
${project.version}
+
+ org.openhab.addons.bundles
+ org.openhab.binding.draytonwiser
+ ${project.version}
+
org.openhab.addons.bundles
org.openhab.binding.dscalarm
diff --git a/bundles/org.openhab.binding.draytonwiser/.classpath b/bundles/org.openhab.binding.draytonwiser/.classpath
new file mode 100644
index 0000000000000..83d1737acf808
--- /dev/null
+++ b/bundles/org.openhab.binding.draytonwiser/.classpath
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/bundles/org.openhab.binding.draytonwiser/.project b/bundles/org.openhab.binding.draytonwiser/.project
new file mode 100644
index 0000000000000..b39c3da8e218a
--- /dev/null
+++ b/bundles/org.openhab.binding.draytonwiser/.project
@@ -0,0 +1,23 @@
+
+
+ org.openhab.binding.draytonwiser
+
+
+
+
+
+ org.eclipse.jdt.core.javabuilder
+
+
+
+
+ org.eclipse.m2e.core.maven2Builder
+
+
+
+
+
+ org.eclipse.jdt.core.javanature
+ org.eclipse.m2e.core.maven2Nature
+
+
diff --git a/bundles/org.openhab.binding.draytonwiser/NOTICE b/bundles/org.openhab.binding.draytonwiser/NOTICE
new file mode 100644
index 0000000000000..38d625e349232
--- /dev/null
+++ b/bundles/org.openhab.binding.draytonwiser/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.draytonwiser/README.md b/bundles/org.openhab.binding.draytonwiser/README.md
new file mode 100644
index 0000000000000..ef55911140bf3
--- /dev/null
+++ b/bundles/org.openhab.binding.draytonwiser/README.md
@@ -0,0 +1,248 @@
+# Drayton Wiser Binding
+
+This binding integrates the [Drayton Wiser Smart Heating System](https://wiser.draytoncontrols.co.uk/).
+The integration happens through the HeatHub, which acts as an IP gateway to the ZigBee devices (thermostats and TRVs).
+
+## Supported Things
+
+The Drayton Wiser binding supports the following things:
+
+| Bridge | Label | Description |
+|-----------|---------|------------------------------------------------------------------------------------------------------|
+| `heathub` | HeatHub | The network device in the controller that allows us to interact with the other devices in the system |
+
+| Thing | Label | Description |
+|---------------------|-------------------|-------------------------------------------------------------------------------------------|
+| `boiler-controller` | Boiler Controller | The _HeatHub_ attached to the boiler. This also acts as the hub device |
+| `room` | Room Name | Virtual groups of _Room Thermostats_ and _TRVs_ that can have temperatures and humidities |
+| `roomstat` | Thermostat | Wireless thermostats which monitor temperature and humidity, and call for heat |
+| `itrv` | iTRV | Wireless TRVs that monitor temperature, alter the radiator valve state and call for heat |
+| `hotwater` | Hot Water | Virtual thing to manage hot water states |
+| `smart-plug` | Smart Plug | Wireless plug sockets which can be remotely switched |
+
+## Discovery
+
+The HeatHub can be discovered automatically via mDNS, however the `secret` cannot be determined automatically.
+Once the `secret` has been configured, all other devices can be discovered by triggering device discovery again.
+
+## Thing Configuration
+
+### HeatHub Configuration
+
+Once discovered, the HeatHub `secret` needs to be configured.
+There are a few ways to obtain this, assuming you have already configured the system using the Wiser App.
+
+1. Temporarily install a packet sniffing tool on your mobile device. Every request made includes the `secret` in the header.
+2. Enable setup mode on the HeatHub. Connect a machine temporarily to the `WiserHeat_XXXXX` network and browse to `http://192.168.8.1/secret` to obtain the `secret`.
+
+The `refresh` interval defines in seconds, how often the binding will poll the controller for updates.
+
+The `awaySetPoint` defines the temperature in degrees Celsius that will be sent to the heathub when away mode is activated.
+
+## Channels
+
+### Readonly Channels
+
+#### Boiler Controller
+
+| Channel | Item Type | Description |
+|------------------------------|----------------------|----------------------------------------------------------|
+| `heatingOverride` | Switch | State of the heating override button on the controller |
+| `heatChannel1Demand` | Number:Dimensionless | Current demand level of heating channel 1 |
+| `heatChannel1DemandState` | Switch | Is channel 1 calling the boiler for heat |
+| `heatChannel2Demand` | Number:Dimensionless | Current demand level of heating channel 2 |
+| `heatChannel2DemandState` | Switch | Is channel 2 calling the boiler for heat |
+| `currentSignalRSSI` | Number | Relative Signal Strength Indicator |
+| `currentWiserSignalStrength` | String | Human readable signal strength |
+| `currentSignalStrength` | Number | Signal strength value that maps to qualityofservice icon |
+
+#### Hot Water
+
+| Channel | Item Type | Description |
+|--------------------------|--------------|----------------------------------------------------------|
+| `hotWaterOverride` | Switch | State of the hot water override button on the controller |
+| `hotWaterDemandState` | Switch | Is hot water calling the boiler for heat |
+| `hotWaterBoosted` | Switch | Is hot water currently being boosted |
+| `hotWaterBoostRemaining` | Number:Time | How long until the boost deactivates in minutes |
+
+#### Room
+
+| Channel | Item Type | Description |
+|----------------------|----------------------|------------------------------------------------------------------------------|
+| `currentTemperature` | Number:Temperature | Currently reported temperature |
+| `currentHumidity` | Number:Dimensionless | Currently reported humidity (if there is a room stat configured in this room |
+| `currentDemand` | Number:Dimensionless | Current heat demand percentage of the room |
+| `heatRequest` | Switch | Is the room actively requesting heat from the controller |
+| `roomBoosted` | Switch | Is the room currently being boosted |
+| `roomBoostRemaining` | Number:Time | How long until the boost deactivates in minutes |
+| `windowState` | Contact | Is the window open or closed? |
+
+#### Room Stat
+
+| Channel | Item Type | Description |
+|------------------------------|--------------------------|----------------------------------------------------------|
+| `currentTemperature` | Number:Temperature | Currently reported temperature |
+| `currentHumidity` | Number:Dimensionless | Currently reported humidity |
+| `currentSetPoint` | Number:Temperature | Currently reported set point |
+| `currentBatteryVoltage` | Number:ElectricPotential | Currently reported battery voltage |
+| `currentWiserBatteryLevel` | String | Human readable battery level |
+| `currentBatteryLevel` | Number | Battery level in percent |
+| `currentSignalRSSI` | Number | Relative Signal Strength Indicator |
+| `currentSignalLQI` | Number | Link Quality Indicator |
+| `currentWiserSignalStrength` | String | Human readable signal strength |
+| `currentSignalStrength` | Number | Signal strength value that maps to qualityofservice icon |
+| `zigbeeConnected` | Switch | Is the roomstat joined to network |
+
+#### Smart TRV
+
+| Channel | Item Type | Description |
+|------------------------------|--------------------------|----------------------------------------------------------|
+| `currentTemperature` | Number:Temperature | Currently reported temperature |
+| `currentDemand` | Number:Dimensionless | Current heat demand percentage of the TRV |
+| `currentSetPoint` | Number:Temperature | Currently reported set point |
+| `currentBatteryVoltage` | Number:ElectricPotential | Currently reported battery voltage |
+| `currentWiserBatteryLevel` | String | Human readable battery level |
+| `currentBatteryLevel` | Number | Battery level in percent |
+| `currentSignalRSSI` | Number | Relative Signal Strength Indicator |
+| `currentSignalLQI` | Number | Link Quality Indicator |
+| `currentWiserSignalStrength` | String | Human readable signal strength |
+| `currentSignalStrength` | Number | Signal strength value that maps to qualityofservice icon |
+| `zigbeeConnected` | Switch | Is the TRV joined to network |
+
+#### Smart Plug
+
+| Channel | Item Type | Description |
+|---------------------|-----------|------------------------------------|
+| `currentSignalRSSI` | Number | Relative Signal Strength Indicator |
+| `currentSignalLQI` | Number | Link Quality Indicator |
+| `zigbeeConnected` | Switch | Is the TRV joined to network |
+
+### Command Channels
+
+#### Boiler Controller
+
+| Channel | Item Type | Description |
+|-----------------|-----------|----------------------------|
+| `awayModeState` | Switch | Has away mode been enabled |
+| `ecoModeState` | Switch | Has eco mode been enabled |
+
+#### Hot Water
+
+| Channel | Item Type | Description |
+|-------------------------|-----------|--------------------------------------------|
+| `manualModeState` | Switch | Has manual mode been enabled |
+| `hotWaterSetPoint` | Switch | The current hot water setpoint (on or off) |
+| `hotWaterBoostDuration` | Number | Period in hours to boost the hot water |
+
+#### Room
+
+| Channel | Item Type | Description |
+|------------------------|--------------------|------------------------------------------------|
+| `currentSetPoint` | Number:Temperature | The current set point temperature for the room |
+| `manualModeState` | Switch | Has manual mode been enabled |
+| `roomBoostDuration` | Number | Period in hours to boost the room temperature |
+| `windowStateDetection` | Switch | Detect whether windows are open |
+
+#### Room Stat
+
+| Channel | Item Type | Description |
+|----------------|-----------|----------------------------------|
+| `deviceLocked` | Switch | Is the roomstat interface locked |
+
+#### Smart TRV
+
+| Channel | Item Type | Description |
+|----------------|-----------|-----------------------------|
+| `deviceLocked` | Switch | Are the TRV controls locked |
+
+#### Smart Plug
+
+| Channel | Item Type | Description |
+|-------------------|-----------|----------------------------------------------|
+| `plugOutputState` | Switch | The current on/off state of the smart plug |
+| `plugAwayAction` | Switch | Should the plug switch off when in away mode |
+| `manualModeState` | Switch | Has manual mode been enabled |
+| `deviceLocked` | Switch | Are the Smart Plug controls locked |
+
+#### Known string responses for specific channels:
+
+| Channel | Known responses |
+|------------------------------|--------------------------------------------------------------------|
+| `currentWiserSignalStrength` | `{ "VeryGood", "Good", "Medium", "Poor", "NoSignal" }` |
+| `currentWiserBatteryLevel` | `{ "Full", "Normal", "TwoThirds", "OneThird", "Low", "Critical" }` |
+
+## Full Example
+
+### .things file
+
+```
+Bridge draytonwiser:heathub:HeatHub [ networkAddress="192.168.1.X", refresh=60, secret="secret from hub", awaySetPoint=10 ] {
+ boiler-controller controller "Controller"
+ room livingroom "Living Room" [ name="Living Room" ]
+ room bathroom "Bathroom" [ name="Bathroom" ]
+ room bedroom "Bedroom" [ name="Bedroom" ]
+ roomstat livingroomstat "Living Room Thermostat" [ serialNumber="ABCDEF1234" ]
+ itrv livingroomtrv "Living Room - TRV" [ serialNumber="ABCDEF1235" ]
+ hotwater hotwater "Hot Water"
+ smart-plug tvplug "TV" [ serialNumber="ABCDEF1236" ]
+}
+```
+
+The `name` corresponds to the room name configured in the Wiser App.
+It is not case sensitive.
+The `serialNumber` corresponds to the device serial number which can be found on a sticker inside the battery compartment of the Smart Valves/TRVs, and behind the wall mount of the Room Thermostats.
+
+### .items file
+
+```
+Switch Heating_Override "Heating Override" (gHouse) { channel="draytonwiser:boiler-controller:HeatHub:controller:heatingOverride" }
+Number Heating_Demand "Heating Demand [%.0f %%]" (gHouse) { channel="draytonwiser:boiler-controller:HeatHub:controller:heatChannel1Demand" }
+Switch Heating_Requesting_Heat "Heating On" (gHouse) { channel="draytonwiser:boiler-controller:HeatHub:controller:heatChannel1DemandState" }
+Switch Heating_Away_Mode "Away Mode" (gHouse) { channel="draytonwiser:boiler-controller:HeatHub:controller:awayModeState" }
+Switch Heating_Eco_Mode "Eco Mode" (gHouse) { channel="draytonwiser:boiler-controller:HeatHub:controller:ecoModeState" }
+
+/* Heating */
+Switch Heating_GF_Living "Heating" (GF_Living, Heating) ["Heat Request"] { channel="draytonwiser:room:HeatHub:livingroom:heatRequest" }
+
+/* Indoor Temperatures */
+Number:Temperature livingroom_temperature "Temperature [%.1f °C]" (GF_Living, Temperature) ["Temperature"] {channel="draytonwiser:room:HeatHub:livingroom:currentTemperature"}
+
+/* Setpoint Temperatures */
+Number:Temperature livingroom_setpoint "Set Point [%.1f °C]" (GF_Living) ["Set Point"] {channel="draytonwiser:room:HeatHub:livingroom:currentSetPoint"}
+
+/* Heat Demand */
+Number livingroom_heatdemand "Heat Demand [%.0f %%]" (GF_Living) ["Heat Demand"] {channel="draytonwiser:room:HeatHub:livingroom:currentDemand"}
+
+/* Manual Mode */
+Switch ManualMode_GF_Living "Manual Mode" (GF_Living) ["Manual Mode"] { channel="draytonwiser:room:HeatHub:livingroom:manualModeState" }
+
+/* Boost Mode */
+Switch BoostMode_GF_Living "Boosted" (GF_Living) ["Boost Mode"] { channel="draytonwiser:room:HeatHub:livingroom:roomBoosted" }
+
+/* Boost Duration */
+Number BoostDuration_GF_Living "Boost For[]" (GF_Living) ["Boost Duration"] { channel="draytonwiser:room:HeatHub:livingroom:roomBoostDuration" }
+
+/* Boost Remaining */
+Number BoostRemaining_GF_Living "Boost Remaining" (GF_Living) ["Boost Remaining"] { channel="draytonwiser:room:HeatHub:livingroom:roomBoostRemaining" }
+
+/* Humidity */
+Number:Humidity livingroom_humidity "Humidity [%.0f %%]" (GF_Living) ["Humidity"] {channel="draytonwiser:room:HeatHub:livingroom:currentHumidity"}
+
+
+```
+
+### Sitemap
+
+```
+Text label="Living Room" icon="sofa" {
+ Text item=livingroom_temperature
+ Setpoint item=livingroom_setpoint step=0.5
+ Text item=livingroom_humidity
+ Text item=Heating_GF_Living
+ Text item=livingroom_heatdemand
+ Switch item=ManualMode_GF_Living
+ Text item=BoostMode_GF_Living
+ Switch item=BoostDuration_GF_Living icon="time" mappings=[0="0", 0.5="0.5", 1="1", 2="2", 3="3"]
+ Text item=BoostRemaining_GF_Living icon="time"
+}
+```
diff --git a/bundles/org.openhab.binding.draytonwiser/pom.xml b/bundles/org.openhab.binding.draytonwiser/pom.xml
new file mode 100644
index 0000000000000..7807fc154025e
--- /dev/null
+++ b/bundles/org.openhab.binding.draytonwiser/pom.xml
@@ -0,0 +1,17 @@
+
+
+
+ 4.0.0
+
+
+ org.openhab.addons.bundles
+ org.openhab.addons.reactor.bundles
+ 2.5.8-SNAPSHOT
+
+
+ org.openhab.binding.draytonwiser
+
+ openHAB Add-ons :: Bundles :: DraytonWiser Binding
+
+
diff --git a/bundles/org.openhab.binding.draytonwiser/src/main/feature/feature.xml b/bundles/org.openhab.binding.draytonwiser/src/main/feature/feature.xml
new file mode 100644
index 0000000000000..004c64d107142
--- /dev/null
+++ b/bundles/org.openhab.binding.draytonwiser/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.draytonwiser/${project.version}
+
+
diff --git a/bundles/org.openhab.binding.draytonwiser/src/main/java/org/openhab/binding/draytonwiser/internal/DraytonWiserBindingConstants.java b/bundles/org.openhab.binding.draytonwiser/src/main/java/org/openhab/binding/draytonwiser/internal/DraytonWiserBindingConstants.java
new file mode 100644
index 0000000000000..6074e44e961e1
--- /dev/null
+++ b/bundles/org.openhab.binding.draytonwiser/src/main/java/org/openhab/binding/draytonwiser/internal/DraytonWiserBindingConstants.java
@@ -0,0 +1,160 @@
+/**
+ * Copyright (c) 2010-2020 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.draytonwiser.internal;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.smarthome.core.library.types.DecimalType;
+import org.eclipse.smarthome.core.thing.ThingTypeUID;
+import org.eclipse.smarthome.core.types.State;
+import org.eclipse.smarthome.core.types.UnDefType;
+
+/**
+ * The {@link DraytonWiserBindingConstants} class defines common constants, which are
+ * used across the whole binding.
+ *
+ * @author Andrew Schofield - Initial contribution
+ */
+@NonNullByDefault
+public class DraytonWiserBindingConstants {
+
+ public static final String BINDING_ID = "draytonwiser";
+
+ public static final String REFRESH_INTERVAL = "refresh";
+ public static final int DEFAULT_REFRESH_SECONDS = 60;
+
+ public static final int OFFLINE_TEMPERATURE = -32768;
+
+ // Web Service Endpoints
+ public static final String DEVICE_ENDPOINT = "data/domain/Device/";
+ public static final String ROOMSTATS_ENDPOINT = "data/domain/RoomStat/";
+ public static final String TRVS_ENDPOINT = "data/domain/SmartValve/";
+ public static final String ROOMS_ENDPOINT = "data/domain/Room/";
+ public static final String HEATCHANNELS_ENDPOINT = "data/domain/HeatingChannel/";
+ public static final String SYSTEM_ENDPOINT = "data/domain/System/";
+ public static final String STATION_ENDPOINT = "data/network/Station/";
+ public static final String DOMAIN_ENDPOINT = "data/domain/";
+ public static final String HOTWATER_ENDPOINT = "data/domain/HotWater/";
+ public static final String SMARTPLUG_ENDPOINT = "data/domain/SmartPlug/";
+
+ // bridge
+ public static final ThingTypeUID THING_TYPE_BRIDGE = new ThingTypeUID(BINDING_ID, "heathub");
+
+ // List of all Thing Type UIDs
+ public static final ThingTypeUID THING_TYPE_CONTROLLER = new ThingTypeUID(BINDING_ID, "boiler-controller");
+ public static final ThingTypeUID THING_TYPE_ROOM = new ThingTypeUID(BINDING_ID, "room");
+ public static final ThingTypeUID THING_TYPE_ROOMSTAT = new ThingTypeUID(BINDING_ID, "roomstat");
+ public static final ThingTypeUID THING_TYPE_ITRV = new ThingTypeUID(BINDING_ID, "itrv");
+ public static final ThingTypeUID THING_TYPE_HOTWATER = new ThingTypeUID(BINDING_ID, "hotwater");
+ public static final ThingTypeUID THING_TYPE_SMARTPLUG = new ThingTypeUID(BINDING_ID, "smart-plug");
+
+ // properties
+ public static final String PROP_ADDRESS = "networkAddress";
+ public static final String PROP_SERIAL_NUMBER = "serialNumber";
+ public static final String PROP_NAME = "name";
+ public static final String PROP_ID = "id";
+
+ // List of all Channel ids
+ public static final String CHANNEL_CURRENT_TEMPERATURE = "currentTemperature";
+ public static final String CHANNEL_CURRENT_HUMIDITY = "currentHumidity";
+ public static final String CHANNEL_CURRENT_SETPOINT = "currentSetPoint";
+ public static final String CHANNEL_CURRENT_BATTERY_VOLTAGE = "currentBatteryVoltage";
+ public static final String CHANNEL_CURRENT_BATTERY_LEVEL = "currentBatteryLevel";
+ public static final String CHANNEL_CURRENT_WISER_BATTERY_LEVEL = "currentWiserBatteryLevel";
+ public static final String CHANNEL_CURRENT_DEMAND = "currentDemand";
+ public static final String CHANNEL_HEAT_REQUEST = "heatRequest";
+ public static final String CHANNEL_CURRENT_SIGNAL_RSSI = "currentSignalRSSI";
+ public static final String CHANNEL_CURRENT_SIGNAL_LQI = "currentSignalLQI";
+ public static final String CHANNEL_CURRENT_SIGNAL_STRENGTH = "currentSignalStrength";
+ public static final String CHANNEL_CURRENT_WISER_SIGNAL_STRENGTH = "currentWiserSignalStrength";
+ public static final String CHANNEL_HEATING_OVERRIDE = "heatingOverride";
+ public static final String CHANNEL_HOT_WATER_OVERRIDE = "hotWaterOverride";
+ public static final String CHANNEL_HEATCHANNEL_1_DEMAND = "heatChannel1Demand";
+ public static final String CHANNEL_HEATCHANNEL_2_DEMAND = "heatChannel2Demand";
+ public static final String CHANNEL_HEATCHANNEL_1_DEMAND_STATE = "heatChannel1DemandState";
+ public static final String CHANNEL_HEATCHANNEL_2_DEMAND_STATE = "heatChannel2DemandState";
+ public static final String CHANNEL_HOTWATER_DEMAND_STATE = "hotWaterDemandState";
+ public static final String CHANNEL_AWAY_MODE_STATE = "awayModeState";
+ public static final String CHANNEL_ECO_MODE_STATE = "ecoModeState";
+ public static final String CHANNEL_MANUAL_MODE_STATE = "manualModeState";
+ public static final String CHANNEL_ZIGBEE_CONNECTED = "zigbeeConnected";
+ public static final String CHANNEL_HOT_WATER_SETPOINT = "hotWaterSetPoint";
+ public static final String CHANNEL_HOT_WATER_BOOST_DURATION = "hotWaterBoostDuration";
+ public static final String CHANNEL_HOT_WATER_BOOSTED = "hotWaterBoosted";
+ public static final String CHANNEL_HOT_WATER_BOOST_REMAINING = "hotWaterBoostRemaining";
+ public static final String CHANNEL_ROOM_BOOST_DURATION = "roomBoostDuration";
+ public static final String CHANNEL_ROOM_BOOSTED = "roomBoosted";
+ public static final String CHANNEL_ROOM_BOOST_REMAINING = "roomBoostRemaining";
+ public static final String CHANNEL_ROOM_WINDOW_STATE_DETECTION = "windowStateDetection";
+ public static final String CHANNEL_ROOM_WINDOW_STATE = "windowState";
+ public static final String CHANNEL_DEVICE_LOCKED = "deviceLocked";
+ public static final String CHANNEL_SMARTPLUG_OUTPUT_STATE = "plugOutputState";
+ public static final String CHANNEL_SMARTPLUG_AWAY_ACTION = "plugAwayAction";
+
+ public static final Set SUPPORTED_THING_TYPES_UIDS = Collections
+ .unmodifiableSet(new HashSet<>(Arrays.asList(THING_TYPE_CONTROLLER, THING_TYPE_ROOM, THING_TYPE_ROOMSTAT,
+ THING_TYPE_BRIDGE, THING_TYPE_ITRV, THING_TYPE_HOTWATER, THING_TYPE_SMARTPLUG)));
+
+ // Lookups from text representations to useful values
+
+ public enum SignalStrength {
+ VERYGOOD(4),
+ GOOD(3),
+ MEDIUM(2),
+ POOR(1),
+ NOSIGNAL(0);
+
+ private final int signalStrength;
+
+ SignalStrength(final int signalStrength) {
+ this.signalStrength = signalStrength;
+ }
+
+ public static State toSignalStrength(final String strength) {
+ try {
+ return new DecimalType(SignalStrength.valueOf(strength.toUpperCase()).signalStrength);
+ } catch (final IllegalArgumentException e) {
+ // Catch unrecognized values.
+ return UnDefType.UNDEF;
+ }
+ }
+ }
+
+ public enum BatteryLevel {
+ FULL(100),
+ NORMAL(80),
+ TWOTHIRDS(60),
+ ONETHIRD(40),
+ LOW(20),
+ CRITICAL(0);
+
+ private final int batteryLevel;
+
+ private BatteryLevel(final int batteryLevel) {
+ this.batteryLevel = batteryLevel;
+ }
+
+ public static State toBatteryLevel(final String level) {
+ try {
+ return new DecimalType(BatteryLevel.valueOf(level.toUpperCase()).batteryLevel);
+ } catch (final IllegalArgumentException e) {
+ // Catch unrecognized values.
+ return UnDefType.UNDEF;
+ }
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.draytonwiser/src/main/java/org/openhab/binding/draytonwiser/internal/DraytonWiserHandlerFactory.java b/bundles/org.openhab.binding.draytonwiser/src/main/java/org/openhab/binding/draytonwiser/internal/DraytonWiserHandlerFactory.java
new file mode 100644
index 0000000000000..6724d3989284c
--- /dev/null
+++ b/bundles/org.openhab.binding.draytonwiser/src/main/java/org/openhab/binding/draytonwiser/internal/DraytonWiserHandlerFactory.java
@@ -0,0 +1,80 @@
+/**
+ * Copyright (c) 2010-2020 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.draytonwiser.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.smarthome.core.thing.Bridge;
+import org.eclipse.smarthome.core.thing.Thing;
+import org.eclipse.smarthome.core.thing.ThingTypeUID;
+import org.eclipse.smarthome.core.thing.binding.BaseThingHandlerFactory;
+import org.eclipse.smarthome.core.thing.binding.ThingHandler;
+import org.eclipse.smarthome.core.thing.binding.ThingHandlerFactory;
+import org.eclipse.smarthome.io.net.http.HttpClientFactory;
+import org.openhab.binding.draytonwiser.internal.handler.ControllerHandler;
+import org.openhab.binding.draytonwiser.internal.handler.HeatHubHandler;
+import org.openhab.binding.draytonwiser.internal.handler.HotWaterHandler;
+import org.openhab.binding.draytonwiser.internal.handler.RoomHandler;
+import org.openhab.binding.draytonwiser.internal.handler.RoomStatHandler;
+import org.openhab.binding.draytonwiser.internal.handler.SmartPlugHandler;
+import org.openhab.binding.draytonwiser.internal.handler.TRVHandler;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+
+/**
+ * The {@link DraytonWiserHandlerFactory} is responsible for creating things and thing
+ * handlers.
+ *
+ * @author Andrew Schofield - Initial contribution
+ */
+@Component(service = ThingHandlerFactory.class, configurationPid = "binding.draytonwiser")
+@NonNullByDefault
+public class DraytonWiserHandlerFactory extends BaseThingHandlerFactory {
+
+ private final HttpClient httpClient;
+
+ @Activate
+ public DraytonWiserHandlerFactory(@Reference final HttpClientFactory factory) {
+ httpClient = factory.getCommonHttpClient();
+ }
+
+ @Override
+ public boolean supportsThingType(final ThingTypeUID thingTypeUID) {
+ return DraytonWiserBindingConstants.SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
+ }
+
+ @Override
+ protected @Nullable ThingHandler createHandler(final Thing thing) {
+ final ThingTypeUID thingTypeUID = thing.getThingTypeUID();
+
+ if (DraytonWiserBindingConstants.THING_TYPE_BRIDGE.equals(thingTypeUID)) {
+ return new HeatHubHandler((Bridge) thing, httpClient);
+ } else if (DraytonWiserBindingConstants.THING_TYPE_ROOM.equals(thingTypeUID)) {
+ return new RoomHandler(thing);
+ } else if (DraytonWiserBindingConstants.THING_TYPE_ROOMSTAT.equals(thingTypeUID)) {
+ return new RoomStatHandler(thing);
+ } else if (DraytonWiserBindingConstants.THING_TYPE_ITRV.equals(thingTypeUID)) {
+ return new TRVHandler(thing);
+ } else if (DraytonWiserBindingConstants.THING_TYPE_CONTROLLER.equals(thingTypeUID)) {
+ return new ControllerHandler(thing);
+ } else if (DraytonWiserBindingConstants.THING_TYPE_HOTWATER.equals(thingTypeUID)) {
+ return new HotWaterHandler(thing);
+ } else if (DraytonWiserBindingConstants.THING_TYPE_SMARTPLUG.equals(thingTypeUID)) {
+ return new SmartPlugHandler(thing);
+ }
+
+ return null;
+ }
+}
diff --git a/bundles/org.openhab.binding.draytonwiser/src/main/java/org/openhab/binding/draytonwiser/internal/DraytonWiserRefreshListener.java b/bundles/org.openhab.binding.draytonwiser/src/main/java/org/openhab/binding/draytonwiser/internal/DraytonWiserRefreshListener.java
new file mode 100644
index 0000000000000..773b93770be2d
--- /dev/null
+++ b/bundles/org.openhab.binding.draytonwiser/src/main/java/org/openhab/binding/draytonwiser/internal/DraytonWiserRefreshListener.java
@@ -0,0 +1,27 @@
+/**
+ * Copyright (c) 2010-2020 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.draytonwiser.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.draytonwiser.internal.model.DraytonWiserDTO;
+
+/**
+ * Listener for item/sensor updates.
+ *
+ * @author Andrew Schofield - Initial contribution
+ */
+@NonNullByDefault
+public interface DraytonWiserRefreshListener {
+
+ void onRefresh(DraytonWiserDTO domain);
+}
diff --git a/bundles/org.openhab.binding.draytonwiser/src/main/java/org/openhab/binding/draytonwiser/internal/api/DraytonWiserApi.java b/bundles/org.openhab.binding.draytonwiser/src/main/java/org/openhab/binding/draytonwiser/internal/api/DraytonWiserApi.java
new file mode 100644
index 0000000000000..8c03af39f90f9
--- /dev/null
+++ b/bundles/org.openhab.binding.draytonwiser/src/main/java/org/openhab/binding/draytonwiser/internal/api/DraytonWiserApi.java
@@ -0,0 +1,225 @@
+/**
+ * Copyright (c) 2010-2020 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.draytonwiser.internal.api;
+
+import static org.openhab.binding.draytonwiser.internal.DraytonWiserBindingConstants.*;
+
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.client.api.ContentResponse;
+import org.eclipse.jetty.client.util.StringContentProvider;
+import org.eclipse.jetty.http.HttpMethod;
+import org.eclipse.jetty.http.HttpStatus;
+import org.openhab.binding.draytonwiser.internal.handler.HeatHubConfiguration;
+import org.openhab.binding.draytonwiser.internal.model.DomainDTO;
+import org.openhab.binding.draytonwiser.internal.model.StationDTO;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.FieldNamingPolicy;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonSyntaxException;
+
+/**
+ * Class with api specific call code.
+ *
+ * @author Andrew Schofield - Initial contribution
+ * @author Hilbrand Bouwkamp - Moved Api specific code to it's own class
+ */
+@NonNullByDefault
+public class DraytonWiserApi {
+
+ public static final Gson GSON = new GsonBuilder().setFieldNamingStrategy(FieldNamingPolicy.UPPER_CAMEL_CASE)
+ .create();
+
+ private final Logger logger = LoggerFactory.getLogger(DraytonWiserApi.class);
+ private final HttpClient httpClient;
+
+ private HeatHubConfiguration configuration = new HeatHubConfiguration();
+ private int failCount;
+
+ public DraytonWiserApi(final HttpClient httpClient) {
+ this.httpClient = httpClient;
+ }
+
+ public void setConfiguration(final HeatHubConfiguration configuration) {
+ this.configuration = configuration;
+ }
+
+ public @Nullable StationDTO getStation() throws DraytonWiserApiException {
+ final ContentResponse response = sendMessageToHeatHub(STATION_ENDPOINT, HttpMethod.GET);
+
+ return response == null ? null : GSON.fromJson(response.getContentAsString(), StationDTO.class);
+ }
+
+ public @Nullable DomainDTO getDomain() throws DraytonWiserApiException {
+ final ContentResponse response = sendMessageToHeatHub(DOMAIN_ENDPOINT, HttpMethod.GET);
+
+ if (response == null) {
+ return null;
+ }
+
+ try {
+ return GSON.fromJson(response.getContentAsString(), DomainDTO.class);
+ } catch (final JsonSyntaxException e) {
+ logger.debug("Could not parse Json content: {}", e.getMessage(), e);
+ return null;
+ }
+ }
+
+ public void setRoomSetPoint(final int roomId, final int setPoint) throws DraytonWiserApiException {
+ final String payload = "{\"RequestOverride\":{\"Type\":\"Manual\", \"SetPoint\":" + setPoint + "}}";
+
+ sendMessageToHeatHub(ROOMS_ENDPOINT + roomId, "PATCH", payload);
+ }
+
+ public void setRoomManualMode(final int roomId, final boolean manualMode) throws DraytonWiserApiException {
+ String payload = "{\"Mode\":\"" + (manualMode ? "Manual" : "Auto") + "\"}";
+ sendMessageToHeatHub(ROOMS_ENDPOINT + roomId, "PATCH", payload);
+ payload = "{\"RequestOverride\":{\"Type\":\"None\",\"Originator\" :\"App\",\"DurationMinutes\":0,\"SetPoint\":0}}";
+ sendMessageToHeatHub(ROOMS_ENDPOINT + roomId, "PATCH", payload);
+ }
+
+ public void setRoomWindowStateDetection(final int roomId, final boolean windowStateDetection)
+ throws DraytonWiserApiException {
+ final String payload = windowStateDetection ? "true" : "false";
+ sendMessageToHeatHub(ROOMS_ENDPOINT + roomId + "/WindowDetectionActive", "PATCH", payload);
+ }
+
+ public void setRoomBoostActive(final int roomId, final int setPoint, final int duration)
+ throws DraytonWiserApiException {
+ final String payload = "{\"RequestOverride\":{\"Type\":\"Manual\",\"Originator\" :\"App\",\"DurationMinutes\":"
+ + duration + ",\"SetPoint\":" + setPoint + "}}";
+ sendMessageToHeatHub(ROOMS_ENDPOINT + roomId, "PATCH", payload);
+ }
+
+ public void setRoomBoostInactive(final int roomId) throws DraytonWiserApiException {
+ final String payload = "{\"RequestOverride\":{\"Type\":\"None\",\"Originator\" :\"App\",\"DurationMinutes\":0,\"SetPoint\":0}}";
+ sendMessageToHeatHub(ROOMS_ENDPOINT + roomId, "PATCH", payload);
+ }
+
+ public void setHotWaterManualMode(final boolean manualMode) throws DraytonWiserApiException {
+ String payload = "{\"Mode\":\"" + (manualMode ? "Manual" : "Auto") + "\"}";
+ sendMessageToHeatHub(HOTWATER_ENDPOINT + "2", "PATCH", payload);
+ payload = "{\"RequestOverride\":{\"Type\":\"None\",\"Originator\" :\"App\",\"DurationMinutes\":0,\"SetPoint\":0}}";
+ sendMessageToHeatHub(HOTWATER_ENDPOINT + "2", "PATCH", payload);
+ }
+
+ public void setHotWaterSetPoint(final int setPoint) throws DraytonWiserApiException {
+ final String payload = "{\"RequestOverride\":{\"Type\":\"Manual\", \"SetPoint\":" + setPoint + "}}";
+ sendMessageToHeatHub(HOTWATER_ENDPOINT + "2", "PATCH", payload);
+ }
+
+ public void setHotWaterBoostActive(final int duration) throws DraytonWiserApiException {
+ final String payload = "{\"RequestOverride\":{\"Type\":\"Manual\",\"Originator\" :\"App\",\"DurationMinutes\":"
+ + duration + ",\"SetPoint\":1100}}";
+ sendMessageToHeatHub(HOTWATER_ENDPOINT + "2", "PATCH", payload);
+ }
+
+ public void setHotWaterBoostInactive() throws DraytonWiserApiException {
+ final String payload = "{\"RequestOverride\":{\"Type\":\"None\",\"Originator\" :\"App\",\"DurationMinutes\":0,\"SetPoint\":0}}";
+ sendMessageToHeatHub(HOTWATER_ENDPOINT + "2", "PATCH", payload);
+ }
+
+ public void setAwayMode(final boolean awayMode) throws DraytonWiserApiException {
+ final int setPoint = configuration.awaySetPoint * 10;
+
+ String payload = "{\"Type\":" + (awayMode ? "2" : "0") + ", \"setPoint\":" + (awayMode ? setPoint : "0") + "}";
+ sendMessageToHeatHub(SYSTEM_ENDPOINT + "RequestOverride", "PATCH", payload);
+ payload = "{\"Type\":" + (awayMode ? "2" : "0") + ", \"setPoint\":" + (awayMode ? "-200" : "0") + "}";
+ sendMessageToHeatHub(HOTWATER_ENDPOINT + "2/RequestOverride", "PATCH", payload);
+ }
+
+ public void setDeviceLocked(final int deviceId, final boolean locked) throws DraytonWiserApiException {
+ final String payload = locked ? "true" : "false";
+ sendMessageToHeatHub(DEVICE_ENDPOINT + deviceId + "/DeviceLockEnabled", "PATCH", payload);
+ }
+
+ public void setEcoMode(final boolean ecoMode) throws DraytonWiserApiException {
+ final String payload = "{\"EcoModeEnabled\":" + ecoMode + "}";
+ sendMessageToHeatHub(SYSTEM_ENDPOINT, "PATCH", payload);
+ }
+
+ public void setSmartPlugManualMode(final int id, final boolean manualMode) throws DraytonWiserApiException {
+ final String payload = "{\"Mode\":\"" + (manualMode ? "Manual" : "Auto") + "\"}";
+ sendMessageToHeatHub(SMARTPLUG_ENDPOINT + id, "PATCH", payload);
+ }
+
+ public void setSmartPlugOutputState(final int id, final boolean outputState) throws DraytonWiserApiException {
+ final String payload = "{\"RequestOutput\":\"" + (outputState ? "On" : "Off") + "\"}";
+ sendMessageToHeatHub(SMARTPLUG_ENDPOINT + id, "PATCH", payload);
+ }
+
+ public void setSmartPlugAwayAction(final int id, final boolean awayAction) throws DraytonWiserApiException {
+ final String payload = "{\"AwayAction\":\"" + (awayAction ? "Off" : "NoChange") + "\"}";
+ sendMessageToHeatHub(SMARTPLUG_ENDPOINT + id, "PATCH", payload);
+ }
+
+ private synchronized @Nullable ContentResponse sendMessageToHeatHub(final String path, final HttpMethod method)
+ throws DraytonWiserApiException {
+ return sendMessageToHeatHub(path, method.asString(), "");
+ }
+
+ private synchronized @Nullable ContentResponse sendMessageToHeatHub(final String path, final String method,
+ final String content) throws DraytonWiserApiException {
+ // we need to keep track of the number of times that the heat hub has "failed" to respond.
+ // we only actually report a failure if we hit an error state 3 or more times
+ try {
+ logger.debug("Sending message to heathub: {}", path);
+ final StringContentProvider contentProvider = new StringContentProvider(content);
+ final ContentResponse response = httpClient
+ .newRequest("http://" + configuration.networkAddress + "/" + path).method(method)
+ .header("SECRET", configuration.secret).content(contentProvider).timeout(10, TimeUnit.SECONDS)
+ .send();
+
+ if (logger.isTraceEnabled()) {
+ logger.trace("Reponse (Status:{}): {}", response.getStatus(), response.getContentAsString());
+ }
+ if (response.getStatus() == HttpStatus.OK_200) {
+ failCount = 0;
+ return response;
+ } else if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
+ failCount++;
+ if (failCount > 2) {
+ throw new DraytonWiserApiException("Invalid authorization token");
+ }
+ } else {
+ failCount++;
+ if (failCount > 2) {
+ throw new DraytonWiserApiException("Heathub didn't repond after " + failCount + " retries");
+ }
+ }
+ } catch (final TimeoutException e) {
+ failCount++;
+ if (failCount > 2) {
+ logger.debug("Heathub didn't repond in time: {}", e.getMessage());
+ throw new DraytonWiserApiException("Heathub didn't repond in time", e);
+ }
+ } catch (final InterruptedException e) {
+ Thread.currentThread().interrupt();
+ } catch (final ExecutionException e) {
+ logger.debug("Execution Exception: {}", e.getMessage(), e);
+ throw new DraytonWiserApiException(e.getMessage(), e);
+ } catch (final RuntimeException e) {
+ logger.debug("Unexpected error: {}", e.getMessage(), e);
+ throw new DraytonWiserApiException(e.getMessage(), e);
+ }
+ return null;
+ }
+}
diff --git a/bundles/org.openhab.binding.draytonwiser/src/main/java/org/openhab/binding/draytonwiser/internal/api/DraytonWiserApiException.java b/bundles/org.openhab.binding.draytonwiser/src/main/java/org/openhab/binding/draytonwiser/internal/api/DraytonWiserApiException.java
new file mode 100644
index 0000000000000..0430fd5e0f0c8
--- /dev/null
+++ b/bundles/org.openhab.binding.draytonwiser/src/main/java/org/openhab/binding/draytonwiser/internal/api/DraytonWiserApiException.java
@@ -0,0 +1,34 @@
+/**
+ * Copyright (c) 2010-2020 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.draytonwiser.internal.api;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Exception thrown in case of api problems.
+ *
+ * @author Hilbrand Bouwkamp - Initial contribution
+ */
+@NonNullByDefault
+public class DraytonWiserApiException extends Exception {
+
+ private static final long serialVersionUID = 1L;
+
+ public DraytonWiserApiException(final String message) {
+ super(message);
+ }
+
+ public DraytonWiserApiException(final String message, final Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/bundles/org.openhab.binding.draytonwiser/src/main/java/org/openhab/binding/draytonwiser/internal/discovery/DraytonWiserDiscoveryService.java b/bundles/org.openhab.binding.draytonwiser/src/main/java/org/openhab/binding/draytonwiser/internal/discovery/DraytonWiserDiscoveryService.java
new file mode 100644
index 0000000000000..78351d379c684
--- /dev/null
+++ b/bundles/org.openhab.binding.draytonwiser/src/main/java/org/openhab/binding/draytonwiser/internal/discovery/DraytonWiserDiscoveryService.java
@@ -0,0 +1,209 @@
+/**
+ * Copyright (c) 2010-2020 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.draytonwiser.internal.discovery;
+
+import static org.openhab.binding.draytonwiser.internal.DraytonWiserBindingConstants.*;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.smarthome.config.discovery.AbstractDiscoveryService;
+import org.eclipse.smarthome.config.discovery.DiscoveryResult;
+import org.eclipse.smarthome.config.discovery.DiscoveryResultBuilder;
+import org.eclipse.smarthome.config.discovery.DiscoveryService;
+import org.eclipse.smarthome.core.thing.ThingTypeUID;
+import org.eclipse.smarthome.core.thing.ThingUID;
+import org.eclipse.smarthome.core.thing.binding.ThingHandler;
+import org.eclipse.smarthome.core.thing.binding.ThingHandlerService;
+import org.openhab.binding.draytonwiser.internal.DraytonWiserRefreshListener;
+import org.openhab.binding.draytonwiser.internal.handler.DraytonWiserPropertyHelper;
+import org.openhab.binding.draytonwiser.internal.handler.HeatHubHandler;
+import org.openhab.binding.draytonwiser.internal.model.DeviceDTO;
+import org.openhab.binding.draytonwiser.internal.model.DraytonWiserDTO;
+import org.openhab.binding.draytonwiser.internal.model.HotWaterDTO;
+import org.openhab.binding.draytonwiser.internal.model.RoomDTO;
+import org.openhab.binding.draytonwiser.internal.model.RoomStatDTO;
+import org.openhab.binding.draytonwiser.internal.model.SmartPlugDTO;
+import org.openhab.binding.draytonwiser.internal.model.SmartValveDTO;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link DraytonWiserDiscoveryService} is used to discover devices that are connected to a Heat Hub.
+ *
+ * @author Andrew Schofield - Initial contribution
+ */
+@NonNullByDefault
+public class DraytonWiserDiscoveryService extends AbstractDiscoveryService
+ implements DiscoveryService, ThingHandlerService, DraytonWiserRefreshListener {
+
+ private final Logger logger = LoggerFactory.getLogger(DraytonWiserDiscoveryService.class);
+
+ private @Nullable HeatHubHandler bridgeHandler;
+ private @Nullable ThingUID bridgeUID;
+
+ public DraytonWiserDiscoveryService() {
+ super(SUPPORTED_THING_TYPES_UIDS, 30, false);
+ }
+
+ @Override
+ public void activate() {
+ super.activate(null);
+ }
+
+ @Override
+ public void deactivate() {
+ super.deactivate();
+ }
+
+ @Override
+ protected void startScan() {
+ final HeatHubHandler handler = bridgeHandler;
+ if (handler != null) {
+ removeOlderResults(getTimestampOfLastScan());
+ handler.setDiscoveryService(this);
+ }
+ }
+
+ @Override
+ public void onRefresh(final DraytonWiserDTO domainDTOProxy) {
+ logger.debug("Received data from Drayton Wise device. Parsing to discover devices.");
+ onControllerAdded(domainDTOProxy);
+ domainDTOProxy.getRooms().forEach(this::onRoomAdded);
+ domainDTOProxy.getRoomStats().forEach(r -> onRoomStatAdded(domainDTOProxy, r));
+ domainDTOProxy.getSmartValves().forEach(sv -> onSmartValveAdded(domainDTOProxy, sv));
+ domainDTOProxy.getHotWater().forEach(hw -> onHotWaterAdded(domainDTOProxy, hw));
+ domainDTOProxy.getSmartPlugs().forEach(sp -> onSmartPlugAdded(domainDTOProxy, sp));
+ }
+
+ private void onControllerAdded(final DraytonWiserDTO domainDTOProxy) {
+ final DeviceDTO device = domainDTOProxy.getExtendedDeviceProperties(0);
+
+ if (device != null) {
+ logger.debug("Controller discovered, model: {}", device.getModelIdentifier());
+ onThingWithId(THING_TYPE_CONTROLLER, "controller", device, "Controller");
+ }
+ }
+
+ private void onHotWaterAdded(final DraytonWiserDTO domainDTOProxy, final HotWaterDTO hotWater) {
+ final Integer hotWaterId = hotWater.getId();
+ final String roomName = getRoomName(domainDTOProxy, hotWaterId);
+
+ onThingWithId(THING_TYPE_HOTWATER, "hotwater" + hotWaterId, null,
+ (roomName.isEmpty() ? "" : (roomName + " - ")) + "Hot Water");
+ }
+
+ private void onThingWithId(final ThingTypeUID deviceType, final String deviceTypeId,
+ @Nullable final DeviceDTO device, final String name) {
+ logger.debug("{} discovered: {}", deviceTypeId, name);
+ final Map properties = new HashMap<>();
+
+ properties.put(PROP_ID, deviceTypeId);
+ if (device != null) {
+ DraytonWiserPropertyHelper.setGeneralDeviceProperties(device, properties);
+ }
+ final DiscoveryResult discoveryResult = DiscoveryResultBuilder
+ .create(new ThingUID(deviceType, bridgeUID, deviceTypeId)).withBridge(bridgeUID)
+ .withProperties(properties).withRepresentationProperty(PROP_ID).withLabel(name).build();
+
+ thingDiscovered(discoveryResult);
+ }
+
+ private void onRoomStatAdded(final DraytonWiserDTO domainDTOProxy, final RoomStatDTO roomStat) {
+ final Integer roomStatId = roomStat.getId();
+ final DeviceDTO device = domainDTOProxy.getExtendedDeviceProperties(roomStatId);
+
+ if (device != null) {
+ onThingWithSerialNumber(THING_TYPE_ROOMSTAT, "Thermostat", device, getRoomName(domainDTOProxy, roomStatId));
+ }
+ }
+
+ private void onRoomAdded(final RoomDTO room) {
+ final Map properties = new HashMap<>();
+
+ logger.debug("Room discovered: {}", room.getName());
+ properties.put(PROP_NAME, room.getName());
+ final DiscoveryResult discoveryResult = DiscoveryResultBuilder
+ .create(new ThingUID(THING_TYPE_ROOM, bridgeUID,
+ room.getName().replaceAll("[^A-Za-z0-9]", "").toLowerCase()))
+ .withBridge(bridgeUID).withProperties(properties).withRepresentationProperty(PROP_NAME)
+ .withLabel(room.getName()).build();
+
+ thingDiscovered(discoveryResult);
+ }
+
+ private void onSmartValveAdded(final DraytonWiserDTO domainDTOProxy, final SmartValveDTO smartValve) {
+ final Integer smartValueId = smartValve.getId();
+ final DeviceDTO device = domainDTOProxy.getExtendedDeviceProperties(smartValueId);
+
+ if (device != null) {
+ onThingWithSerialNumber(THING_TYPE_ITRV, "TRV", device, getRoomName(domainDTOProxy, smartValueId));
+ }
+ }
+
+ private void onSmartPlugAdded(final DraytonWiserDTO domainDTOProxy, final SmartPlugDTO smartPlug) {
+ final DeviceDTO device = domainDTOProxy.getExtendedDeviceProperties(smartPlug.getId());
+
+ if (device != null) {
+ onThingWithSerialNumber(THING_TYPE_SMARTPLUG, "Smart Plug", device, smartPlug.getName());
+ }
+ }
+
+ private String getRoomName(final DraytonWiserDTO domainDTOProxy, final Integer roomId) {
+ final RoomDTO assignedRoom = domainDTOProxy.getRoomForDeviceId(roomId);
+ return assignedRoom == null ? "" : assignedRoom.getName();
+ }
+
+ private void onThingWithSerialNumber(final ThingTypeUID deviceType, final String deviceTypeName,
+ final DeviceDTO device, final String name) {
+ final String serialNumber = device.getSerialNumber();
+ logger.debug("{} discovered, serialnumber: {}", deviceTypeName, serialNumber);
+ final Map properties = new HashMap<>();
+
+ DraytonWiserPropertyHelper.setPropertiesWithSerialNumber(device, properties);
+ final DiscoveryResult discoveryResult = DiscoveryResultBuilder
+ .create(new ThingUID(deviceType, bridgeUID, serialNumber)).withBridge(bridgeUID)
+ .withProperties(properties).withRepresentationProperty(PROP_SERIAL_NUMBER)
+ .withLabel((name.isEmpty() ? "" : (name + " - ")) + deviceTypeName).build();
+
+ thingDiscovered(discoveryResult);
+ }
+
+ @Override
+ public synchronized void stopScan() {
+ final HeatHubHandler handler = bridgeHandler;
+
+ if (handler != null) {
+ handler.unsetDiscoveryService();
+ }
+ super.stopScan();
+ }
+
+ @Override
+ public void setThingHandler(@Nullable final ThingHandler handler) {
+ if (handler instanceof HeatHubHandler) {
+ bridgeHandler = (HeatHubHandler) handler;
+ bridgeUID = handler.getThing().getUID();
+ } else {
+ bridgeHandler = null;
+ bridgeUID = null;
+ }
+ }
+
+ @Override
+ public @Nullable ThingHandler getThingHandler() {
+ return bridgeHandler;
+ }
+}
diff --git a/bundles/org.openhab.binding.draytonwiser/src/main/java/org/openhab/binding/draytonwiser/internal/discovery/DraytonWiserMDNSDiscoveryParticipant.java b/bundles/org.openhab.binding.draytonwiser/src/main/java/org/openhab/binding/draytonwiser/internal/discovery/DraytonWiserMDNSDiscoveryParticipant.java
new file mode 100644
index 0000000000000..007eca1aa52f0
--- /dev/null
+++ b/bundles/org.openhab.binding.draytonwiser/src/main/java/org/openhab/binding/draytonwiser/internal/discovery/DraytonWiserMDNSDiscoveryParticipant.java
@@ -0,0 +1,90 @@
+/**
+ * Copyright (c) 2010-2020 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.draytonwiser.internal.discovery;
+
+import static org.openhab.binding.draytonwiser.internal.DraytonWiserBindingConstants.*;
+
+import java.net.InetAddress;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+import javax.jmdns.ServiceInfo;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.smarthome.config.discovery.DiscoveryResult;
+import org.eclipse.smarthome.config.discovery.DiscoveryResultBuilder;
+import org.eclipse.smarthome.config.discovery.mdns.MDNSDiscoveryParticipant;
+import org.eclipse.smarthome.core.thing.ThingTypeUID;
+import org.eclipse.smarthome.core.thing.ThingUID;
+import org.osgi.service.component.annotations.Component;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link DraytonWiserMDNSDiscoveryParticipant} is responsible for discovering Drayton Wiser Heat Hubs. It uses the
+ * central MDNS Discovery Service.
+ *
+ * @author Andrew Schofield - Initial contribution
+ *
+ */
+@NonNullByDefault
+@Component(service = MDNSDiscoveryParticipant.class, configurationPid = "mdnsdiscovery.draytonwiser")
+public class DraytonWiserMDNSDiscoveryParticipant implements MDNSDiscoveryParticipant {
+
+ private final Logger logger = LoggerFactory.getLogger(DraytonWiserMDNSDiscoveryParticipant.class);
+
+ @Override
+ public Set getSupportedThingTypeUIDs() {
+ return Collections.singleton(THING_TYPE_BRIDGE);
+ }
+
+ @Override
+ public String getServiceType() {
+ return "_http._tcp.local.";
+ }
+
+ @Override
+ public @Nullable DiscoveryResult createResult(final ServiceInfo service) {
+ if (service.getApplication().contains("http")) {
+ final ThingUID uid = getThingUID(service);
+
+ if (uid != null) {
+ logger.debug("Discovered Heat Hub '{}' with uid: {}", service.getName(), uid);
+ final Map properties = new HashMap<>(2);
+ final InetAddress[] addresses = service.getInetAddresses();
+
+ if (addresses.length > 0 && addresses[0] != null) {
+ properties.put(PROP_ADDRESS, addresses[0].getHostAddress());
+ properties.put(REFRESH_INTERVAL, DEFAULT_REFRESH_SECONDS);
+ }
+
+ return DiscoveryResultBuilder.create(uid).withProperties(properties)
+ .withRepresentationProperty(PROP_ADDRESS).withLabel("Heat Hub - " + service.getName()).build();
+ }
+ }
+ return null;
+ }
+
+ @Override
+ public @Nullable ThingUID getThingUID(final ServiceInfo service) {
+ if (service.getType() != null && service.getType().equals(getServiceType())
+ && service.getName().contains("WiserHeat")) {
+ logger.trace("Discovered a Drayton Wiser Heat Hub thing with name '{}'", service.getName());
+ return new ThingUID(THING_TYPE_BRIDGE, service.getName());
+ }
+ return null;
+ }
+}
diff --git a/bundles/org.openhab.binding.draytonwiser/src/main/java/org/openhab/binding/draytonwiser/internal/handler/ControllerHandler.java b/bundles/org.openhab.binding.draytonwiser/src/main/java/org/openhab/binding/draytonwiser/internal/handler/ControllerHandler.java
new file mode 100644
index 0000000000000..76ca6630e6d8f
--- /dev/null
+++ b/bundles/org.openhab.binding.draytonwiser/src/main/java/org/openhab/binding/draytonwiser/internal/handler/ControllerHandler.java
@@ -0,0 +1,166 @@
+/**
+ * Copyright (c) 2010-2020 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.draytonwiser.internal.handler;
+
+import static org.openhab.binding.draytonwiser.internal.DraytonWiserBindingConstants.*;
+
+import java.util.List;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.smarthome.core.library.types.DecimalType;
+import org.eclipse.smarthome.core.library.types.OnOffType;
+import org.eclipse.smarthome.core.library.types.QuantityType;
+import org.eclipse.smarthome.core.library.types.StringType;
+import org.eclipse.smarthome.core.library.unit.SmartHomeUnits;
+import org.eclipse.smarthome.core.thing.Thing;
+import org.eclipse.smarthome.core.types.Command;
+import org.eclipse.smarthome.core.types.State;
+import org.eclipse.smarthome.core.types.UnDefType;
+import org.openhab.binding.draytonwiser.internal.DraytonWiserBindingConstants.SignalStrength;
+import org.openhab.binding.draytonwiser.internal.api.DraytonWiserApiException;
+import org.openhab.binding.draytonwiser.internal.handler.ControllerHandler.ControllerData;
+import org.openhab.binding.draytonwiser.internal.model.DeviceDTO;
+import org.openhab.binding.draytonwiser.internal.model.DraytonWiserDTO;
+import org.openhab.binding.draytonwiser.internal.model.HeatingChannelDTO;
+import org.openhab.binding.draytonwiser.internal.model.HotWaterDTO;
+import org.openhab.binding.draytonwiser.internal.model.StationDTO;
+import org.openhab.binding.draytonwiser.internal.model.SystemDTO;
+
+/**
+ * The {@link ControllerHandler} is responsible for handling commands, which are
+ * sent to one of the channels.
+ *
+ * @author Andrew Schofield - Initial contribution
+ * @author Hilbrand Bouwkamp - Simplified handler to handle null data
+ */
+@NonNullByDefault
+public class ControllerHandler extends DraytonWiserThingHandler {
+
+ public ControllerHandler(final Thing thing) {
+ super(thing);
+ }
+
+ @Override
+ protected void handleCommand(final String channelId, final Command command) throws DraytonWiserApiException {
+ if (command instanceof OnOffType) {
+ final boolean onOffState = OnOffType.ON.equals(command);
+
+ if (CHANNEL_AWAY_MODE_STATE.equals(channelId)) {
+ setAwayMode(onOffState);
+ } else if (CHANNEL_ECO_MODE_STATE.equals(channelId)) {
+ setEcoMode(onOffState);
+ }
+ }
+ }
+
+ @Override
+ protected void refresh() {
+ updateState(CHANNEL_HEATING_OVERRIDE, this::getHeatingOverride);
+ updateState(CHANNEL_CURRENT_SIGNAL_RSSI, this::getRSSI);
+ updateState(CHANNEL_CURRENT_WISER_SIGNAL_STRENGTH, this::getWiserSignalStrength);
+ updateState(CHANNEL_CURRENT_SIGNAL_STRENGTH, this::getSignalStrength);
+ updateState(CHANNEL_HEATCHANNEL_1_DEMAND, this::getHeatChannel1Demand);
+ updateState(CHANNEL_HEATCHANNEL_2_DEMAND, this::getHeatChannel2Demand);
+ updateState(CHANNEL_HEATCHANNEL_1_DEMAND_STATE, this::getHeatChannel1DemandState);
+ updateState(CHANNEL_HEATCHANNEL_2_DEMAND_STATE, this::getHeatChannel2DemandState);
+ updateState(CHANNEL_AWAY_MODE_STATE, this::getAwayModeState);
+ updateState(CHANNEL_ECO_MODE_STATE, this::getEcoModeState);
+ }
+
+ @Override
+ protected @Nullable ControllerData collectData(final DraytonWiserDTO domainDTOProxy)
+ throws DraytonWiserApiException {
+ final StationDTO station = getApi().getStation();
+ final DeviceDTO device = domainDTOProxy.getExtendedDeviceProperties(0);
+ final SystemDTO system = domainDTOProxy.getSystem();
+ final List heatingChannels = domainDTOProxy.getHeatingChannels();
+ final List hotWaterChannels = domainDTOProxy.getHotWater();
+
+ return station != null && device != null && system != null
+ ? new ControllerData(device, system, station, heatingChannels, hotWaterChannels)
+ : null;
+ }
+
+ private State getHeatingOverride() {
+ return OnOffType.from("ON".equalsIgnoreCase(getData().system.getHeatingButtonOverrideState()));
+ }
+
+ private State getRSSI() {
+ return new DecimalType(getData().station.getRSSI().getCurrent());
+ }
+
+ private State getWiserSignalStrength() {
+ return new StringType(getData().device.getDisplayedSignalStrength());
+ }
+
+ private State getSignalStrength() {
+ return SignalStrength.toSignalStrength(getData().device.getDisplayedSignalStrength());
+ }
+
+ private State getHeatChannel1Demand() {
+ return getData().heatingChannels.size() >= 1
+ ? new QuantityType<>(getData().heatingChannels.get(0).getPercentageDemand(), SmartHomeUnits.PERCENT)
+ : UnDefType.UNDEF;
+ }
+
+ private State getHeatChannel2Demand() {
+ return getData().heatingChannels.size() >= 2
+ ? new QuantityType<>(getData().heatingChannels.get(1).getPercentageDemand(), SmartHomeUnits.PERCENT)
+ : UnDefType.UNDEF;
+ }
+
+ private State getHeatChannel1DemandState() {
+ return OnOffType.from(getData().heatingChannels.size() >= 1
+ && "ON".equalsIgnoreCase(getData().heatingChannels.get(0).getHeatingRelayState()));
+ }
+
+ private State getHeatChannel2DemandState() {
+ return OnOffType.from(getData().heatingChannels.size() >= 2
+ && "ON".equalsIgnoreCase(getData().heatingChannels.get(1).getHeatingRelayState()));
+ }
+
+ private State getAwayModeState() {
+ return OnOffType.from(getData().system.getOverrideType() != null
+ && "AWAY".equalsIgnoreCase(getData().system.getOverrideType()));
+ }
+
+ private State getEcoModeState() {
+ return OnOffType.from(getData().system.getEcoModeEnabled() != null && getData().system.getEcoModeEnabled());
+ }
+
+ private void setAwayMode(final Boolean awayMode) throws DraytonWiserApiException {
+ getApi().setAwayMode(awayMode);
+ }
+
+ private void setEcoMode(final Boolean ecoMode) throws DraytonWiserApiException {
+ getApi().setEcoMode(ecoMode);
+ }
+
+ static class ControllerData {
+ public final DeviceDTO device;
+ public final SystemDTO system;
+ public final StationDTO station;
+ public final List heatingChannels;
+ public final List hotWaterChannels;
+
+ public ControllerData(final DeviceDTO device, final SystemDTO system, final StationDTO station,
+ final List heatingChannels, final List hotWaterChannels) {
+ this.device = device;
+ this.system = system;
+ this.station = station;
+ this.heatingChannels = heatingChannels;
+ this.hotWaterChannels = hotWaterChannels;
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.draytonwiser/src/main/java/org/openhab/binding/draytonwiser/internal/handler/DraytonWiserPropertyHelper.java b/bundles/org.openhab.binding.draytonwiser/src/main/java/org/openhab/binding/draytonwiser/internal/handler/DraytonWiserPropertyHelper.java
new file mode 100644
index 0000000000000..4b2c4d2bf790c
--- /dev/null
+++ b/bundles/org.openhab.binding.draytonwiser/src/main/java/org/openhab/binding/draytonwiser/internal/handler/DraytonWiserPropertyHelper.java
@@ -0,0 +1,46 @@
+/**
+ * Copyright (c) 2010-2020 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.draytonwiser.internal.handler;
+
+import static org.openhab.binding.draytonwiser.internal.DraytonWiserBindingConstants.PROP_SERIAL_NUMBER;
+
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.draytonwiser.internal.model.DeviceDTO;
+
+/**
+ *
+ * @author Andrew Schofield - Initial contribution
+ * @author Hilbrand Bouwkamp - Put all device property setting in a separate class
+ */
+@NonNullByDefault
+public final class DraytonWiserPropertyHelper {
+
+ private DraytonWiserPropertyHelper() {
+ // helper class
+ }
+
+ public static void setPropertiesWithSerialNumber(final DeviceDTO device, final Map properties) {
+ properties.put(PROP_SERIAL_NUMBER, device.getSerialNumber());
+ setGeneralDeviceProperties(device, properties);
+ }
+
+ public static void setGeneralDeviceProperties(final DeviceDTO device,
+ final Map properties) {
+ properties.put("Device Type", device.getProductIdentifier());
+ properties.put("Firmware Version", device.getActiveFirmwareVersion());
+ properties.put("Manufacturer", device.getManufacturer());
+ properties.put("Model", device.getModelIdentifier());
+ }
+}
diff --git a/bundles/org.openhab.binding.draytonwiser/src/main/java/org/openhab/binding/draytonwiser/internal/handler/DraytonWiserThingHandler.java b/bundles/org.openhab.binding.draytonwiser/src/main/java/org/openhab/binding/draytonwiser/internal/handler/DraytonWiserThingHandler.java
new file mode 100644
index 0000000000000..1ab4483234295
--- /dev/null
+++ b/bundles/org.openhab.binding.draytonwiser/src/main/java/org/openhab/binding/draytonwiser/internal/handler/DraytonWiserThingHandler.java
@@ -0,0 +1,222 @@
+/**
+ * Copyright (c) 2010-2020 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.draytonwiser.internal.handler;
+
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.smarthome.core.thing.Bridge;
+import org.eclipse.smarthome.core.thing.ChannelUID;
+import org.eclipse.smarthome.core.thing.Thing;
+import org.eclipse.smarthome.core.thing.ThingStatus;
+import org.eclipse.smarthome.core.thing.ThingStatusDetail;
+import org.eclipse.smarthome.core.thing.ThingStatusInfo;
+import org.eclipse.smarthome.core.thing.binding.BaseThingHandler;
+import org.eclipse.smarthome.core.thing.util.ThingHandlerHelper;
+import org.eclipse.smarthome.core.types.Command;
+import org.eclipse.smarthome.core.types.RefreshType;
+import org.eclipse.smarthome.core.types.State;
+import org.eclipse.smarthome.core.types.UnDefType;
+import org.openhab.binding.draytonwiser.internal.DraytonWiserRefreshListener;
+import org.openhab.binding.draytonwiser.internal.api.DraytonWiserApi;
+import org.openhab.binding.draytonwiser.internal.api.DraytonWiserApiException;
+import org.openhab.binding.draytonwiser.internal.model.DraytonWiserDTO;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link DraytonWiserThingHandler} is responsible for handling commands, which are
+ * sent to one of the channels.
+ *
+ * @author Andrew Schofield - Initial contribution
+ * @author Hilbrand Bouwkamp - Moved generic code from subclasses to this class
+ */
+@NonNullByDefault
+abstract class DraytonWiserThingHandler extends BaseThingHandler implements DraytonWiserRefreshListener {
+
+ private final Logger logger = LoggerFactory.getLogger(getClass());
+
+ private @Nullable DraytonWiserApi api;
+ private @Nullable T data;
+ private @Nullable DraytonWiserDTO draytonWiseDTO;
+ private @Nullable ScheduledFuture> handleCommandRefreshFuture;
+
+ protected DraytonWiserThingHandler(final Thing thing) {
+ super(thing);
+ }
+
+ @Override
+ public void initialize() {
+ final HeatHubHandler bridgeHandler = getHeatHubHandler();
+
+ if (bridgeHandler == null) {
+ api = null;
+ } else {
+ api = bridgeHandler.getApi();
+ updateStatus(ThingStatus.UNKNOWN);
+ }
+ }
+
+ @Override
+ public final void handleCommand(final ChannelUID channelUID, final Command command) {
+ final HeatHubHandler heatHubHandler = getHeatHubHandler();
+
+ if (heatHubHandler == null) {
+ return; // if null status will be updated to offline
+ }
+ if (command instanceof RefreshType) {
+ heatHubHandler.refresh();
+ } else {
+ final DraytonWiserApi api = this.api;
+
+ if (api != null && data != null) {
+ try {
+ handleCommand(channelUID.getId(), command);
+ // cancel previous refresh, but wait for it to finish, so no forced cancel
+ disposehandleCommandRefreshFuture(false);
+ // update the state after the heathub has had time to react
+ handleCommandRefreshFuture = scheduler.schedule(heatHubHandler::refresh, 5, TimeUnit.SECONDS);
+ } catch (final DraytonWiserApiException e) {
+ logger.warn("Failed to handle command {} for channel {}: {}", command, channelUID, e.getMessage());
+ logger.trace("DraytonWiserApiException", e);
+ }
+ }
+ }
+ }
+
+ private void disposehandleCommandRefreshFuture(final boolean force) {
+ final ScheduledFuture> future = handleCommandRefreshFuture;
+
+ if (future != null) {
+ future.cancel(force);
+ }
+ }
+
+ @Override
+ public final void dispose() {
+ disposehandleCommandRefreshFuture(true);
+ }
+
+ /**
+ * Performs the actual command. This method is only called when api and device cache are not null.
+ *
+ * @param channelId Channel id part of the Channel UID
+ * @param command the command to perform
+ * @throws DraytonWiserApiException
+ */
+ protected abstract void handleCommand(String channelId, Command command) throws DraytonWiserApiException;
+
+ @Override
+ public final void onRefresh(final DraytonWiserDTO draytonWiseDTO) {
+ this.draytonWiseDTO = draytonWiseDTO;
+ try {
+ if (ThingHandlerHelper.isHandlerInitialized(this)) {
+ data = api == null ? null : collectData(draytonWiseDTO);
+ refresh();
+ if (data == null) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
+ "No data received");
+ } else {
+ if (getThing().getStatus() != ThingStatus.ONLINE) {
+ updateStatus(ThingStatus.ONLINE);
+ }
+ }
+ }
+ } catch (final RuntimeException | DraytonWiserApiException e) {
+ logger.debug("Exception occurred during refresh: {}", e.getMessage(), e);
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
+ }
+ }
+
+ /**
+ * Called to refresh the channels state.
+ */
+ protected abstract void refresh();
+
+ /**
+ * Conditionally updates the state. If no data or no api set the state will be set to UNDEF.
+ *
+ * @param channelId String id of the channel to update
+ * @param stateFunction function to return the state, called when api and data are available
+ */
+ protected void updateState(final String channelId, final Supplier stateFunction) {
+ final State state = api == null || data == null ? UnDefType.UNDEF : stateFunction.get();
+
+ updateState(channelId, state);
+ }
+
+ /**
+ * Returns the handler specific data object only if all data is available.
+ * If not all data is available it should return null.
+ *
+ * @param draytonWiseDTO data object with domain data as received from the hub
+ * @return handler data object if available else null
+ * @throws DraytonWiserApiException
+ */
+ protected abstract @Nullable T collectData(DraytonWiserDTO draytonWiseDTO) throws DraytonWiserApiException;
+
+ protected DraytonWiserApi getApi() {
+ final DraytonWiserApi api = this.api;
+
+ if (api == null) {
+ throw new IllegalStateException("API not set");
+ }
+ return api;
+ }
+
+ protected T getData() {
+ final @Nullable T data = this.data;
+
+ if (data == null) {
+ throw new IllegalStateException("Data not set");
+ }
+ return data;
+ }
+
+ protected DraytonWiserDTO getDraytonWiseDTO() {
+ final DraytonWiserDTO draytonWiseDTO = this.draytonWiseDTO;
+
+ if (draytonWiseDTO == null) {
+ throw new IllegalStateException("DraytonWiseDTO not set");
+ }
+ return draytonWiseDTO;
+ }
+
+ @Override
+ public void bridgeStatusChanged(final ThingStatusInfo bridgeStatusInfo) {
+ if (bridgeStatusInfo.getStatus() == ThingStatus.ONLINE) {
+ if (getThing().getStatus() != ThingStatus.ONLINE) {
+ final HeatHubHandler bridgeHandler = getHeatHubHandler();
+
+ api = bridgeHandler == null ? null : bridgeHandler.getApi();
+ updateStatus(ThingStatus.UNKNOWN);
+ }
+ } else {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
+ }
+ }
+
+ private @Nullable HeatHubHandler getHeatHubHandler() {
+ final Bridge bridge = getBridge();
+
+ if (bridge == null) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
+ return null;
+ } else {
+ return (HeatHubHandler) bridge.getHandler();
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.draytonwiser/src/main/java/org/openhab/binding/draytonwiser/internal/handler/HeatHubConfiguration.java b/bundles/org.openhab.binding.draytonwiser/src/main/java/org/openhab/binding/draytonwiser/internal/handler/HeatHubConfiguration.java
new file mode 100644
index 0000000000000..f2f2d20e29677
--- /dev/null
+++ b/bundles/org.openhab.binding.draytonwiser/src/main/java/org/openhab/binding/draytonwiser/internal/handler/HeatHubConfiguration.java
@@ -0,0 +1,26 @@
+/**
+ * Copyright (c) 2010-2020 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.draytonwiser.internal.handler;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * @author Hilbrand Bouwkamp - Initial contribution
+ */
+@NonNullByDefault
+public class HeatHubConfiguration {
+ public String networkAddress = "";
+ public String secret = "";
+ public int refresh;
+ public int awaySetPoint;
+}
diff --git a/bundles/org.openhab.binding.draytonwiser/src/main/java/org/openhab/binding/draytonwiser/internal/handler/HeatHubHandler.java b/bundles/org.openhab.binding.draytonwiser/src/main/java/org/openhab/binding/draytonwiser/internal/handler/HeatHubHandler.java
new file mode 100644
index 0000000000000..95d12abec0d6f
--- /dev/null
+++ b/bundles/org.openhab.binding.draytonwiser/src/main/java/org/openhab/binding/draytonwiser/internal/handler/HeatHubHandler.java
@@ -0,0 +1,170 @@
+/**
+ * Copyright (c) 2010-2020 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.draytonwiser.internal.handler;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Map;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.smarthome.core.cache.ExpiringCache;
+import org.eclipse.smarthome.core.thing.Bridge;
+import org.eclipse.smarthome.core.thing.ChannelUID;
+import org.eclipse.smarthome.core.thing.Thing;
+import org.eclipse.smarthome.core.thing.ThingStatus;
+import org.eclipse.smarthome.core.thing.ThingStatusDetail;
+import org.eclipse.smarthome.core.thing.binding.BaseBridgeHandler;
+import org.eclipse.smarthome.core.thing.binding.ThingHandler;
+import org.eclipse.smarthome.core.thing.binding.ThingHandlerService;
+import org.eclipse.smarthome.core.thing.util.ThingHandlerHelper;
+import org.eclipse.smarthome.core.types.Command;
+import org.openhab.binding.draytonwiser.internal.DraytonWiserRefreshListener;
+import org.openhab.binding.draytonwiser.internal.api.DraytonWiserApi;
+import org.openhab.binding.draytonwiser.internal.api.DraytonWiserApiException;
+import org.openhab.binding.draytonwiser.internal.discovery.DraytonWiserDiscoveryService;
+import org.openhab.binding.draytonwiser.internal.model.DeviceDTO;
+import org.openhab.binding.draytonwiser.internal.model.DomainDTO;
+import org.openhab.binding.draytonwiser.internal.model.DraytonWiserDTO;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link HeatHubHandler} is responsible for handling commands, which are
+ * sent to one of the channels.
+ *
+ * @author Andrew Schofield - Initial contribution
+ * @author Hilbrand Bouwkamp - Moved api and helper code to separate classes
+ */
+@NonNullByDefault
+public class HeatHubHandler extends BaseBridgeHandler {
+
+ private final Logger logger = LoggerFactory.getLogger(HeatHubHandler.class);
+ private final DraytonWiserApi api;
+ private final ExpiringCache refreshCache = new ExpiringCache<>(3, this::actualRefresh);
+
+ private boolean updateProperties;
+ private @Nullable DraytonWiserRefreshListener discoveryService;
+ private @Nullable ScheduledFuture> refreshJob;
+
+ public HeatHubHandler(final Bridge thing, final HttpClient httpClient) {
+ super(thing);
+ api = new DraytonWiserApi(httpClient);
+ }
+
+ public DraytonWiserApi getApi() {
+ return api;
+ }
+
+ @Override
+ public Collection> getServices() {
+ return Collections.singleton(DraytonWiserDiscoveryService.class);
+ }
+
+ public void setDiscoveryService(final DraytonWiserRefreshListener discoveryService) {
+ this.discoveryService = discoveryService;
+ refreshCache.invalidateValue();
+ refresh();
+ }
+
+ public void unsetDiscoveryService() {
+ discoveryService = null;
+ }
+
+ @Override
+ public void handleCommand(final ChannelUID channelUID, final Command command) {
+ }
+
+ @Override
+ public void initialize() {
+ logger.debug("Initializing Drayton Wiser Heat Hub handler");
+ final HeatHubConfiguration configuration = getConfigAs(HeatHubConfiguration.class);
+ api.setConfiguration(configuration);
+ updateProperties = true;
+ refreshJob = scheduler.scheduleWithFixedDelay(this::refresh, 0, configuration.refresh, TimeUnit.SECONDS);
+ updateStatus(ThingStatus.UNKNOWN);
+ }
+
+ public void refresh() {
+ refreshCache.getValue();
+ }
+
+ private @Nullable Boolean actualRefresh() {
+ try {
+ if (ThingHandlerHelper.isHandlerInitialized(this)) {
+ logger.debug("Refreshing devices");
+ final DomainDTO domain = api.getDomain();
+
+ if (domain == null) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
+ "No data received");
+ } else {
+ if (getThing().getStatus() != ThingStatus.ONLINE) {
+ updateStatus(ThingStatus.ONLINE);
+ }
+ final DraytonWiserDTO draytonWiseDTO = new DraytonWiserDTO(domain);
+
+ updateProperties(draytonWiseDTO);
+ notifyListeners(draytonWiseDTO);
+ }
+ logger.debug("Finished refreshing devices");
+ }
+ } catch (final RuntimeException | DraytonWiserApiException e) {
+ logger.debug("Exception occurred during execution: {}", e.getMessage(), e);
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
+ return null;
+ }
+ return Boolean.TRUE;
+ }
+
+ @Override
+ public void childHandlerInitialized(final ThingHandler childHandler, final Thing childThing) {
+ refresh();
+ }
+
+ private void updateProperties(final DraytonWiserDTO draytonWiseDTO) {
+ if (updateProperties) {
+ final DeviceDTO device = draytonWiseDTO.getExtendedDeviceProperties(0);
+
+ if (device != null) {
+ final Map properties = editProperties();
+ DraytonWiserPropertyHelper.setGeneralDeviceProperties(device, properties);
+ updateProperties(properties);
+ updateProperties = false;
+ }
+ }
+ }
+
+ @Override
+ public void dispose() {
+ final ScheduledFuture> future = refreshJob;
+
+ if (future != null) {
+ future.cancel(true);
+ }
+ }
+
+ private void notifyListeners(final DraytonWiserDTO domain) {
+ final DraytonWiserRefreshListener discoveryListener = discoveryService;
+
+ if (discoveryListener != null) {
+ discoveryListener.onRefresh(domain);
+ }
+ getThing().getThings().stream().map(Thing::getHandler)
+ .filter(handler -> handler instanceof DraytonWiserRefreshListener)
+ .map(DraytonWiserRefreshListener.class::cast).forEach(listener -> listener.onRefresh(domain));
+ }
+}
diff --git a/bundles/org.openhab.binding.draytonwiser/src/main/java/org/openhab/binding/draytonwiser/internal/handler/HotWaterHandler.java b/bundles/org.openhab.binding.draytonwiser/src/main/java/org/openhab/binding/draytonwiser/internal/handler/HotWaterHandler.java
new file mode 100644
index 0000000000000..6679ff8606a5a
--- /dev/null
+++ b/bundles/org.openhab.binding.draytonwiser/src/main/java/org/openhab/binding/draytonwiser/internal/handler/HotWaterHandler.java
@@ -0,0 +1,151 @@
+/**
+ * Copyright (c) 2010-2020 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.draytonwiser.internal.handler;
+
+import static org.openhab.binding.draytonwiser.internal.DraytonWiserBindingConstants.*;
+
+import java.util.List;
+
+import javax.measure.quantity.Time;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.smarthome.core.library.types.DecimalType;
+import org.eclipse.smarthome.core.library.types.OnOffType;
+import org.eclipse.smarthome.core.library.types.QuantityType;
+import org.eclipse.smarthome.core.library.unit.SmartHomeUnits;
+import org.eclipse.smarthome.core.thing.Thing;
+import org.eclipse.smarthome.core.types.Command;
+import org.eclipse.smarthome.core.types.State;
+import org.openhab.binding.draytonwiser.internal.api.DraytonWiserApiException;
+import org.openhab.binding.draytonwiser.internal.handler.HotWaterHandler.HotWaterData;
+import org.openhab.binding.draytonwiser.internal.model.DraytonWiserDTO;
+import org.openhab.binding.draytonwiser.internal.model.HotWaterDTO;
+import org.openhab.binding.draytonwiser.internal.model.SystemDTO;
+
+/**
+ * The {@link HotWaterHandler} is responsible for handling commands, which are
+ * sent to one of the channels.
+ *
+ * @author Andrew Schofield - Initial contribution
+ * @author Hilbrand Bouwkamp - Simplified handler to handle null data
+ */
+@NonNullByDefault
+public class HotWaterHandler extends DraytonWiserThingHandler {
+
+ public HotWaterHandler(final Thing thing) {
+ super(thing);
+ }
+
+ @Override
+ protected void handleCommand(final String channelId, final Command command) throws DraytonWiserApiException {
+ if (command instanceof OnOffType && CHANNEL_MANUAL_MODE_STATE.equals(channelId)) {
+ setManualMode(OnOffType.ON.equals(command));
+ } else if (command instanceof OnOffType && CHANNEL_HOT_WATER_SETPOINT.equals(channelId)) {
+ setSetPoint(OnOffType.ON.equals(command));
+ } else if (command instanceof DecimalType && CHANNEL_HOT_WATER_BOOST_DURATION.equals(channelId)) {
+ setBoostDuration(Math.round((Float.parseFloat(command.toString()) * 60)));
+ }
+ }
+
+ @Override
+ protected void refresh() {
+ updateState(CHANNEL_HOT_WATER_OVERRIDE, this::getHotWaterOverride);
+ updateState(CHANNEL_HOTWATER_DEMAND_STATE, this::getHotWaterDemandState);
+ updateState(CHANNEL_MANUAL_MODE_STATE, this::getManualModeState);
+ updateState(CHANNEL_HOT_WATER_SETPOINT, this::getSetPointState);
+ updateState(CHANNEL_HOT_WATER_BOOSTED, this::getBoostedState);
+ updateState(CHANNEL_HOT_WATER_BOOST_REMAINING, this::getBoostRemainingState);
+ }
+
+ @Override
+ protected @Nullable HotWaterData collectData(final DraytonWiserDTO domainDTOProxy) {
+ final SystemDTO system = domainDTOProxy.getSystem();
+ final List hotWater = domainDTOProxy.getHotWater();
+
+ return system == null ? null : new HotWaterData(system, hotWater);
+ }
+
+ private State getHotWaterOverride() {
+ return OnOffType.from("ON".equalsIgnoreCase(getData().system.getHotWaterButtonOverrideState()));
+ }
+
+ private State getHotWaterDemandState() {
+ final List hotWater = getData().hotWater;
+ return OnOffType.from(hotWater.size() >= 1 && "ON".equalsIgnoreCase(hotWater.get(0).getHotWaterRelayState()));
+ }
+
+ private State getManualModeState() {
+ final List hotWater = getData().hotWater;
+ return OnOffType.from(hotWater.size() >= 1 && "MANUAL".equalsIgnoreCase(hotWater.get(0).getMode()));
+ }
+
+ private State getSetPointState() {
+ final List hotWater = getData().hotWater;
+ return OnOffType.from(hotWater.size() >= 1 && "ON".equalsIgnoreCase(hotWater.get(0).getWaterHeatingState()));
+ }
+
+ private void setManualMode(final boolean manualMode) throws DraytonWiserApiException {
+ getApi().setHotWaterManualMode(manualMode);
+ }
+
+ private void setSetPoint(final boolean setPointMode) throws DraytonWiserApiException {
+ getApi().setHotWaterSetPoint(setPointMode ? 1100 : -200);
+ }
+
+ private void setBoostDuration(final int durationMinutes) throws DraytonWiserApiException {
+ if (durationMinutes > 0) {
+ getApi().setHotWaterBoostActive(durationMinutes);
+ } else {
+ getApi().setHotWaterBoostInactive();
+ }
+ }
+
+ private State getBoostedState() {
+ if (getData().hotWater.size() >= 1) {
+ final HotWaterDTO firstChannel = getData().hotWater.get(0);
+
+ if (firstChannel.getOverrideTimeoutUnixTime() != null
+ && !"NONE".equalsIgnoreCase(firstChannel.getOverrideType())) {
+ return OnOffType.ON;
+ }
+ }
+
+ updateState(CHANNEL_HOT_WATER_BOOST_DURATION, DecimalType.ZERO);
+
+ return OnOffType.OFF;
+ }
+
+ private State getBoostRemainingState() {
+ if (getData().hotWater.size() >= 1) {
+ final HotWaterDTO firstChannel = getData().hotWater.get(0);
+ final Integer overrideTimeout = firstChannel.getOverrideTimeoutUnixTime();
+
+ if (overrideTimeout != null && !"NONE".equalsIgnoreCase(firstChannel.getOverrideType())) {
+ return new QuantityType