diff --git a/CODEOWNERS b/CODEOWNERS
index 7d4e10888..316b63416 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -223,6 +223,7 @@
/bundles/org.openhab.binding.nibeuplink/ @alexf2015
/bundles/org.openhab.binding.nikobus/ @crnjan
/bundles/org.openhab.binding.nikohomecontrol/ @mherwege
+/bundles/org.openhab.binding.nobohub/ @espenaf
/bundles/org.openhab.binding.novafinedust/ @t2000
/bundles/org.openhab.binding.ntp/ @marcelrv
/bundles/org.openhab.binding.nuki/ @janvyb
diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml
index 688265456..f7cb83f0e 100644
--- a/bom/openhab-addons/pom.xml
+++ b/bom/openhab-addons/pom.xml
@@ -1111,6 +1111,11 @@
org.openhab.binding.nikohomecontrol
${project.version}
+
+ org.openhab.addons.bundles
+ org.openhab.binding.nobohub
+ ${project.version}
+
org.openhab.addons.bundles
org.openhab.binding.novafinedust
diff --git a/bundles/org.openhab.binding.nobohub/NOTICE b/bundles/org.openhab.binding.nobohub/NOTICE
new file mode 100644
index 000000000..38d625e34
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/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.nobohub/README.md b/bundles/org.openhab.binding.nobohub/README.md
new file mode 100644
index 000000000..01b671d2e
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/README.md
@@ -0,0 +1,167 @@
+# NoboHub Binding
+
+This binding controls the Glen Dimplex Nobø Hub using the Nobø Hub API v1.1.
+
+
+
+It lets you read and change temperature and profile settings for zones, and read and set active overrides to change the global mode of the hub.
+
+This binding is tested with the following devices:
+
+* Thermostats for different electrical panel heaters
+* Thermostats for heating in floors
+* Nobø Switch SW 4
+
+## Thermostats
+
+Not all thermostats are made equal.
+
+* NCU-1R: Comfort temperature setting on the device overrides values from the Hub, making the setting in the Hub useless.
+* NCU-2R: Synchronizes temperature settings to and from the Hub.
+
+## Supported Things
+
+| Thing | Thing Type | Description |
+|-----------|------------|-------------------------------------------------------------------------------------------------|
+| hub | Bridge | The Nobø Hub provides a gateway between your components, with the ability to organise in zones. |
+| component | Thing | A component is a device, i.e. panel heater or switch. |
+| zone | Thing | A zone can hold one or more components. |
+
+
+## Discovery
+
+The hub will be automatically discovered.
+Before it can be used, you will have to update the configuration with the last three digits of its serial number.
+
+When the hub is configured with the correct serial number, it will autodetect zones and components (thermostats and switches).
+
+## Thing Configuration
+
+```
+# Configuration for Nobø Hub
+#
+# Serial number of the Nobø hub to communicate with, 12 digits.
+serialNumber=103000xxxxxx
+
+# Host name or IP address of the Nobø hub
+hostName=10.0.0.10
+```
+
+## Channels
+
+### Hub
+
+| channel | type | description |
+|---------------------|--------|-----------------------------------------------------|
+| activeOverrideName | String | The name of the active override |
+
+### Zone
+
+| channel | type | description |
+|------------------------------|--------------------|--------------------------------------------|
+| activeWeekProfileName | String | The name of the active week profile |
+| activeWeekProfile | Number | The active week profile id |
+| comfortTemperature | Number:Temperature | The configured comfort temperature |
+| ecoTemperature | Number:Temperature | The configured eco temparature |
+| currentTemperature | Number:Temperature | The current temperature in the zone |
+| calculatedWeekProfileStatus | String | The current override based on week profile |
+
+CurrentTemperature only works if the zone has a device that reports it (e.g. a switch).
+
+### Component
+
+| channel | type | description |
+|---------------------|--------------------|------------------------------------------|
+| currentTemperature | Number:Temperature | The current temperature of the component |
+
+Not all devices report this.
+
+## Full Example
+
+### nobo.things
+
+```
+Bridge nobohub:nobohub:controller "Nobø Hub" [ hostName="192.168.1.10", serialNumber="103000000000" ] {
+ Thing zone 1 "Zone - Kitchen" [ id=1 ]
+ Thing component 184000000000 "Heater - Kitchen" [ serialNumber="184000000000" ]
+}
+```
+
+### nobo.items
+
+```
+// Hub
+String Nobo_Hub_GlobalOverride "Global Override %s" {channel="nobohub:nobohub:controller:activeOverrideName"}
+
+// Panel Heater
+Number:Temperature PanelHeater_CurrentTemperature "Setpoint [%.1f °C]" {channel="nobohub:component:controller:184000000000:currentTemperature"}
+
+// Zone
+String Zone_ActiveWeekProfileName "Active week profile name [%s]" {channel="nobohub:zone:controller:1:activeWeekProfileName"}
+Number Zone_ActiveWeekProfile "Active week profile [%d]" {channel="nobohub:zone:controller:1:activeWeekProfile"}
+String Zone_ActiveStatus "Active status %s]" {channel="nobohub:zone:controller:1:calculatedWeekProfileStatus"}
+Number:Temperature Zone_ComfortTemperature "Comfort temperature [%.1f °C]" {channel="nobohub:zone:controller:1:comfortTemperature"}
+Number:Temperature Zone_EcoTemperatur "Eco temperature [%.1f °C]" {channel="nobohub:zone:controller:1:ecoTemperature"}
+Number:Temperature Zone_CurrentTemperature "Current temperature [%.1f °C]" {channel="nobohub:zone:controller:1:currentTemperature"}
+```
+
+### nobo.sitemap
+
+```
+sitemap nobo label="Nobø " {
+
+ Frame label="Hub"{
+ Switch item=Nobo_Hub_GlobalOverride
+ }
+
+ Frame label="Main Bedroom"{
+ Switch item=Zone_ActiveStatus
+ Text item=Zone_ActiveWeekProfileName
+ Text item=Zone_ActiveWeekProfile
+ Selection item=Zone_ActiveWeekProfile
+ Setpoint item=Zone_ComfortTemperatur minValue=7 maxValue=30 step=1 icon="temperature"
+ Setpoint item=Zone_EcoTemperatur minValue=7 maxValue=30 step=1 icon="temperature"
+ Text item=Zone_CurrentTemperatur
+ Text item=PanelHeater_CurrentTemperatur
+ }
+}
+```
+
+## Organize your setup
+
+Nobø Hub uses a combination of status types (Normal, Comfort, Eco, Away), profiles types (Comfort, Eco, Away, Off), predefined temperature types (Comfort, Eco, Away), zones and override settings to organize and enable different features.
+This makes it possible to control the heaters in many different scenarios and combinations.
+The following is a suggested way of organizing the binding with the Hub for a good level of control and flexibility.
+
+If you own panels with a physical Comfort temperature override, you need to use the Eco temperature type for setting level used by the day based profiles.
+If not, you can use either Comfort or Eco to set wanted level.
+
+Start by creating the following profiles in the Nobø Hub App:
+
+ OFF Set to status off all day, every day.
+ ON Set to status [Comfort|Eco] all day, every day
+ Eco Set to status Eco all day, every day
+ Away Set to status Away all way, every day
+ Weekday 06->16 Set to status [Comfort|Eco] between 06->16 every weekday, otherwise set to [Away|Off]
+ Weekday 06->23 Set to status [Comfort|Eco] between 06->23 every weekday, otherwise set to [Away|Off]
+ Weekend 06->16 Set to status [Comfort|Eco] between 06->16 in the weekend, otherwise set to [Away|Off]
+ Weekend 06->23 Set to status [Comfort|Eco] between 06->23 in the weekend, otherwise set to [Away|Off]
+ Every day 06->16 Set to status [Comfort|Eco] between 06->16 every day, otherwise set to [Away|Off]
+ Every day 06->23 Set to status [Comfort|Eco] between 06->23 every day, otherwise set to [Away|Off]
+
+Next set [Comfort|Eco] level for each zone to your requirements.
+For a more advanced setup, you can create a rule which both sets temperature level and profile.
+
+Then create a sitemap with a Selection pointing to the Week Profile item.
+The binding will now automatically update all available week profile options in the selection button:
+
+### nobo.sitemap
+
+```
+sitemap nobo label="Nobø " {
+
+ Frame label="Main Bedroom"{
+ Selection item=MainBedroom_Zone_WeekProfile
+ }
+}
+```
diff --git a/bundles/org.openhab.binding.nobohub/doc/nobohub.jpg b/bundles/org.openhab.binding.nobohub/doc/nobohub.jpg
new file mode 100644
index 000000000..18915f28b
Binary files /dev/null and b/bundles/org.openhab.binding.nobohub/doc/nobohub.jpg differ
diff --git a/bundles/org.openhab.binding.nobohub/pom.xml b/bundles/org.openhab.binding.nobohub/pom.xml
new file mode 100644
index 000000000..0aefabdcd
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/pom.xml
@@ -0,0 +1,17 @@
+
+
+
+ 4.0.0
+
+
+ org.openhab.addons.bundles
+ org.openhab.addons.reactor.bundles
+ 3.4.0-SNAPSHOT
+
+
+ org.openhab.binding.nobohub
+
+ openHAB Add-ons :: Bundles :: NoboHub Binding
+
+
diff --git a/bundles/org.openhab.binding.nobohub/src/main/feature/feature.xml b/bundles/org.openhab.binding.nobohub/src/main/feature/feature.xml
new file mode 100644
index 000000000..45f55ca04
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/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.nobohub/${project.version}
+
+
diff --git a/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/ComponentConfiguration.java b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/ComponentConfiguration.java
new file mode 100644
index 000000000..b2625588c
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/ComponentConfiguration.java
@@ -0,0 +1,31 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * The {@link ComponentConfiguration} class contains fields mapping thing configuration parameters.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ */
+@NonNullByDefault
+public class ComponentConfiguration {
+
+ /**
+ * Serial number of the component.
+ */
+ @Nullable
+ public String serialNumber;
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/ComponentHandler.java b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/ComponentHandler.java
new file mode 100644
index 000000000..cde394b03
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/ComponentHandler.java
@@ -0,0 +1,159 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal;
+
+import static org.openhab.binding.nobohub.internal.NoboHubBindingConstants.CHANNEL_COMPONENT_CURRENT_TEMPERATURE;
+import static org.openhab.binding.nobohub.internal.NoboHubBindingConstants.PROPERTY_MODEL;
+import static org.openhab.binding.nobohub.internal.NoboHubBindingConstants.PROPERTY_NAME;
+import static org.openhab.binding.nobohub.internal.NoboHubBindingConstants.PROPERTY_TEMPERATURE_SENSOR_FOR_ZONE;
+import static org.openhab.binding.nobohub.internal.NoboHubBindingConstants.PROPERTY_ZONE;
+
+import java.util.Map;
+
+import javax.measure.quantity.Temperature;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.nobohub.internal.model.Component;
+import org.openhab.binding.nobohub.internal.model.SerialNumber;
+import org.openhab.binding.nobohub.internal.model.Zone;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.unit.SIUnits;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.binding.BaseThingHandler;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.RefreshType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Shows information about a Component in the Nobø Hub.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ */
+@NonNullByDefault
+public class ComponentHandler extends BaseThingHandler {
+
+ private final Logger logger = LoggerFactory.getLogger(ComponentHandler.class);
+
+ private final NoboHubTranslationProvider messages;
+
+ protected @Nullable SerialNumber serialNumber;
+
+ public ComponentHandler(Thing thing, NoboHubTranslationProvider messages) {
+ super(thing);
+ this.messages = messages;
+ }
+
+ public void onUpdate(Component component) {
+ updateStatus(ThingStatus.ONLINE);
+
+ double temp = component.getTemperature();
+ if (!Double.isNaN(temp)) {
+ QuantityType currentTemperature = new QuantityType<>(temp, SIUnits.CELSIUS);
+ updateState(CHANNEL_COMPONENT_CURRENT_TEMPERATURE, currentTemperature);
+ }
+
+ Map properties = editProperties();
+ properties.put(Thing.PROPERTY_SERIAL_NUMBER, component.getSerialNumber().toString());
+ properties.put(PROPERTY_NAME, component.getName());
+ properties.put(PROPERTY_MODEL, component.getSerialNumber().getComponentType());
+
+ String zoneName = getZoneName(component.getZoneId());
+ if (zoneName != null) {
+ properties.put(PROPERTY_ZONE, zoneName);
+ }
+
+ String tempForZoneName = getZoneName(component.getTemperatureSensorForZoneId());
+ if (tempForZoneName != null) {
+ properties.put(PROPERTY_TEMPERATURE_SENSOR_FOR_ZONE, tempForZoneName);
+ }
+ updateProperties(properties);
+ }
+
+ private @Nullable String getZoneName(int zoneId) {
+ Bridge noboHub = getBridge();
+ if (null != noboHub) {
+ NoboHubBridgeHandler hubHandler = (NoboHubBridgeHandler) noboHub.getHandler();
+ if (hubHandler != null) {
+ Zone zone = hubHandler.getZone(zoneId);
+ if (null != zone) {
+ return zone.getName();
+ }
+ }
+ }
+
+ return null;
+ }
+
+ @Override
+ public void initialize() {
+ String serialNumberString = getConfigAs(ComponentConfiguration.class).serialNumber;
+ if (serialNumberString != null && !serialNumberString.isEmpty()) {
+ SerialNumber sn = new SerialNumber(serialNumberString);
+ if (!sn.isWellFormed()) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+ "@text/message.component.illegal.serial [\"" + serialNumberString + "\"]");
+ } else {
+ this.serialNumber = sn;
+ updateStatus(ThingStatus.ONLINE);
+ }
+ } else {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/message.missing.serial");
+ }
+ }
+
+ @Override
+ public void handleCommand(ChannelUID channelUID, Command command) {
+ if (command instanceof RefreshType) {
+ logger.debug("Refreshing channel {}", channelUID);
+ if (null != serialNumber) {
+ Component component = getComponent();
+ if (null == component) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.GONE,
+ messages.getText("message.component.notfound", serialNumber, channelUID));
+ } else {
+ onUpdate(component);
+ }
+ } else {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.GONE,
+ "@text/message.component.missing.id [\"" + channelUID + "\"]");
+ }
+
+ return;
+ }
+
+ logger.debug("This component is a read-only device and cannot handle commands.");
+ }
+
+ public @Nullable SerialNumber getSerialNumber() {
+ return serialNumber;
+ }
+
+ private @Nullable Component getComponent() {
+ Bridge noboHub = getBridge();
+ if (null != noboHub) {
+ NoboHubBridgeHandler hubHandler = (NoboHubBridgeHandler) noboHub.getHandler();
+ SerialNumber serialNumber = this.serialNumber;
+ if (null != serialNumber && null != hubHandler) {
+ return hubHandler.getComponent(serialNumber);
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/Helpers.java b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/Helpers.java
new file mode 100644
index 000000000..30ea68baa
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/Helpers.java
@@ -0,0 +1,34 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal;
+
+import java.time.Duration;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Shows information about a Component in the Nobø Hub.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+public class Helpers {
+
+ public static String formatDuration(Duration duration) {
+ long seconds = duration.getSeconds();
+ long absSeconds = Math.abs(seconds);
+ String positive = String.format("%d:%02d:%02d", absSeconds / 3600, (absSeconds % 3600) / 60, absSeconds % 60);
+ return seconds < 0 ? "-" + positive : positive;
+ }
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/NoboHubBindingConstants.java b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/NoboHubBindingConstants.java
new file mode 100644
index 000000000..712598bd6
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/NoboHubBindingConstants.java
@@ -0,0 +1,116 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal;
+
+import java.time.Duration;
+import java.time.format.DateTimeFormatter;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.thing.ThingTypeUID;
+
+/**
+ * The {@link NoboHubBindingConstants} class defines common constants, which are
+ * used across the whole binding.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+public class NoboHubBindingConstants {
+
+ private static final String BINDING_ID = "nobohub";
+
+ public static final String API_VERSION = "1.1";
+
+ public static final String PROPERTY_NAME = "name";
+ public static final String PROPERTY_MODEL = "model";
+ public static final String PROPERTY_HOSTNAME = "hostName";
+
+ public static final String PROPERTY_VENDOR_NAME = "Glen Dimplex Nobø";
+ public static final String PROPERTY_PRODUCTION_DATE = "productionDate";
+
+ public static final String PROPERTY_SOFTWARE_VERSION = "softwareVersion";
+
+ public static final String PROPERTY_ZONE = "zone";
+ public static final String PROPERTY_ZONE_ID = "id";
+ public static final String PROPERTY_TEMPERATURE_SENSOR_FOR_ZONE = "temperatureSensorForZone";
+
+ public static final int NOBO_HUB_TCP_PORT = 27779;
+
+ public static final Duration TIME_BETWEEN_FULL_SCANS = Duration.ofMinutes(10);
+ public static final Duration TIME_BETWEEN_RETRIES_ON_ERROR = Duration.ofSeconds(10);
+
+ public static final Duration RECOMMENDED_KEEPALIVE_INTERVAL = Duration.ofSeconds(14);
+
+ // List of all Thing Type UIDs
+ public static final ThingTypeUID THING_TYPE_HUB = new ThingTypeUID(BINDING_ID, "nobohub");
+ public static final ThingTypeUID THING_TYPE_ZONE = new ThingTypeUID(BINDING_ID, "zone");
+ public static final ThingTypeUID THING_TYPE_COMPONENT = new ThingTypeUID(BINDING_ID, "component");
+
+ public static final Set AUTODISCOVERED_THING_TYPES_UIDS = new HashSet<>(
+ Arrays.asList(THING_TYPE_ZONE, THING_TYPE_COMPONENT));
+
+ public static final Set SUPPORTED_THING_TYPES_UIDS = new HashSet<>(
+ Arrays.asList(THING_TYPE_HUB, THING_TYPE_ZONE, THING_TYPE_COMPONENT));
+
+ // List of all Channel ids
+
+ // Hub
+ public static final String CHANNEL_HUB_ACTIVE_OVERRIDE_NAME = "activeOverrideName";
+
+ // Zone
+ public static final String CHANNEL_ZONE_ACTIVE_WEEK_PROFILE_NAME = "activeWeekProfileName";
+ public static final String CHANNEL_ZONE_ACTIVE_WEEK_PROFILE = "activeWeekProfile";
+ public static final String CHANNEL_ZONE_CALCULATED_WEEK_PROFILE_STATUS = "calculatedWeekProfileStatus";
+ public static final String CHANNEL_ZONE_COMFORT_TEMPERATURE = "comfortTemperature";
+ public static final String CHANNEL_ZONE_ECO_TEMPERATURE = "ecoTemperature";
+ public static final String CHANNEL_ZONE_CURRENT_TEMPERATURE = "currentTemperature";
+
+ // Component
+ public static final String CHANNEL_COMPONENT_CURRENT_TEMPERATURE = "currentTemperature";
+
+ // Date/time
+ public static final DateTimeFormatter DATE_FORMAT_SECONDS = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
+ public static final DateTimeFormatter DATE_FORMAT_MINUTES = DateTimeFormatter.ofPattern("yyyyMMddHHmm");
+ public static final DateTimeFormatter TIME_FORMAT_MINUTES = DateTimeFormatter.ofPattern("HHmm");
+
+ // Discovery
+ public static final int NOBO_HUB_BROADCAST_PORT = 10000;
+ public static final String NOBO_HUB_BROADCAST_ADDRESS = "0.0.0.0";
+ public static final int NOBO_HUB_MULTICAST_PORT = 10001;
+ public static final String NOBO_HUB_MULTICAST_ADDRESS = "239.0.1.187";
+
+ // Mappings
+
+ public static final Map REJECT_REASONS = Stream.of(new String[][] {
+ { "0", "Client command set too old. Please run with debug logs." },
+ { "1", "Hub serial number mismatch. Should be 12 digits, if hub was autodetected, please add the last three." },
+ { "2", "Wrong number of arguments. Please run with debug logs." },
+ { "3", "Timestamp incorrectly formatted. Please run with debug logs." }, })
+ .collect(Collectors.collectingAndThen(Collectors.toMap(data -> data[0], data -> data[1]),
+ Collections:: unmodifiableMap));
+
+ // Full list of units: https://help.nobo.no/skriver/?chapterid=344&chapterlanguageid=2
+ public static final Map SERIALNUMBERS_FOR_TYPES = Stream
+ .of(new String[][] { { "120", "RS-700" }, { "168", "NCU-2R" }, { "184", "NCU-1R" }, { "186", "NTD-4R" },
+ { "192", "TXF" }, { "198", "NCU-ER" }, { "210", "NTB-2R" }, { "234", "Nobø Switch" }, })
+ .collect(Collectors.collectingAndThen(Collectors.toMap(data -> data[0], data -> data[1]),
+ Collections:: unmodifiableMap));
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/NoboHubBridgeConfiguration.java b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/NoboHubBridgeConfiguration.java
new file mode 100644
index 000000000..43c16e314
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/NoboHubBridgeConfiguration.java
@@ -0,0 +1,42 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * The {@link NoboHubBridgeConfiguration} class contains fields mapping thing configuration parameters.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ */
+@NonNullByDefault
+public class NoboHubBridgeConfiguration {
+
+ /**
+ * Serial number of Nobø Hub.
+ */
+ @Nullable
+ public String serialNumber;
+
+ /**
+ * Host address of Nobø Hub.
+ */
+ @Nullable
+ public String hostName;
+
+ /**
+ * Polling interval (seconds)
+ */
+ public int pollingInterval;
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/NoboHubBridgeHandler.java b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/NoboHubBridgeHandler.java
new file mode 100644
index 000000000..309a6bbdd
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/NoboHubBridgeHandler.java
@@ -0,0 +1,418 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal;
+
+import static org.openhab.binding.nobohub.internal.NoboHubBindingConstants.CHANNEL_HUB_ACTIVE_OVERRIDE_NAME;
+import static org.openhab.binding.nobohub.internal.NoboHubBindingConstants.PROPERTY_HOSTNAME;
+import static org.openhab.binding.nobohub.internal.NoboHubBindingConstants.PROPERTY_PRODUCTION_DATE;
+import static org.openhab.binding.nobohub.internal.NoboHubBindingConstants.PROPERTY_SOFTWARE_VERSION;
+import static org.openhab.binding.nobohub.internal.NoboHubBindingConstants.RECOMMENDED_KEEPALIVE_INTERVAL;
+
+import java.time.Duration;
+import java.util.Collection;
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.nobohub.internal.connection.HubCommunicationThread;
+import org.openhab.binding.nobohub.internal.connection.HubConnection;
+import org.openhab.binding.nobohub.internal.discovery.NoboThingDiscoveryService;
+import org.openhab.binding.nobohub.internal.model.Component;
+import org.openhab.binding.nobohub.internal.model.ComponentRegister;
+import org.openhab.binding.nobohub.internal.model.Hub;
+import org.openhab.binding.nobohub.internal.model.NoboCommunicationException;
+import org.openhab.binding.nobohub.internal.model.NoboDataException;
+import org.openhab.binding.nobohub.internal.model.OverrideMode;
+import org.openhab.binding.nobohub.internal.model.OverridePlan;
+import org.openhab.binding.nobohub.internal.model.OverrideRegister;
+import org.openhab.binding.nobohub.internal.model.SerialNumber;
+import org.openhab.binding.nobohub.internal.model.Temperature;
+import org.openhab.binding.nobohub.internal.model.WeekProfile;
+import org.openhab.binding.nobohub.internal.model.WeekProfileRegister;
+import org.openhab.binding.nobohub.internal.model.Zone;
+import org.openhab.binding.nobohub.internal.model.ZoneRegister;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.binding.BaseBridgeHandler;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.RefreshType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link NoboHubBridgeHandler} is responsible for handling commands, which are
+ * sent to one of the channels.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+public class NoboHubBridgeHandler extends BaseBridgeHandler {
+
+ private final Logger logger = LoggerFactory.getLogger(NoboHubBridgeHandler.class);
+ private @Nullable HubCommunicationThread hubThread;
+ private @Nullable NoboThingDiscoveryService discoveryService;
+ private @Nullable Hub hub;
+
+ private final OverrideRegister overrideRegister = new OverrideRegister();
+ private final WeekProfileRegister weekProfileRegister = new WeekProfileRegister();
+ private final ZoneRegister zoneRegister = new ZoneRegister();
+ private final ComponentRegister componentRegister = new ComponentRegister();
+
+ public NoboHubBridgeHandler(Bridge bridge) {
+ super(bridge);
+ }
+
+ @Override
+ public void handleCommand(ChannelUID channelUID, Command command) {
+ logger.info("Handle command {} for channel {}!", command.toFullString(), channelUID);
+
+ HubCommunicationThread ht = this.hubThread;
+ Hub h = this.hub;
+ if (command instanceof RefreshType) {
+ try {
+ if (ht != null) {
+ ht.getConnection().refreshAll();
+ }
+ } catch (NoboCommunicationException noboEx) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+ "@text/message.bridge.status.failed [\"" + noboEx.getMessage() + "\"]");
+ }
+
+ return;
+ }
+
+ if (CHANNEL_HUB_ACTIVE_OVERRIDE_NAME.equals(channelUID.getId())) {
+ if (ht != null && h != null) {
+ if (command instanceof StringType) {
+ StringType strCommand = (StringType) command;
+ logger.debug("Changing override for hub {} to {}", channelUID, strCommand);
+ try {
+ OverrideMode mode = OverrideMode.getByName(strCommand.toFullString());
+ ht.getConnection().setOverride(h, mode);
+ } catch (NoboCommunicationException nce) {
+ logger.debug("Failed setting override mode", nce);
+ } catch (NoboDataException nde) {
+ logger.debug("Date format error setting override mode", nde);
+ }
+ } else {
+ logger.debug("Command of wrong type: {} ({})", command, command.getClass().getName());
+ }
+ } else {
+ if (null == h) {
+ logger.debug("Could not set override, hub not detected yet");
+ }
+
+ if (null == ht) {
+ logger.debug("Could not set override, hub connection thread not set up yet");
+ }
+ }
+ }
+ }
+
+ @Override
+ public void initialize() {
+ NoboHubBridgeConfiguration config = getConfigAs(NoboHubBridgeConfiguration.class);
+
+ String serialNumber = config.serialNumber;
+ if (null == serialNumber) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/message.missing.serial");
+ return;
+ }
+
+ String hostName = config.hostName;
+ if (null == hostName || hostName.isEmpty()) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+ "@text/message.bridge.missing.hostname");
+ return;
+ }
+
+ logger.debug("Looking for Hub {} at {}", config.serialNumber, config.hostName);
+
+ // Set the thing status to UNKNOWN temporarily and let the background task decide for the real status.
+ updateStatus(ThingStatus.UNKNOWN);
+
+ // Background handshake:
+ scheduler.execute(() -> {
+ try {
+ HubConnection conn = new HubConnection(hostName, serialNumber, this);
+ conn.connect();
+
+ logger.debug("Done connecting to {} ({})", hostName, serialNumber);
+
+ Duration timeout = RECOMMENDED_KEEPALIVE_INTERVAL;
+ if (config.pollingInterval > 0) {
+ timeout = Duration.ofSeconds(config.pollingInterval);
+ }
+
+ logger.debug("Starting communication thread to {}", hostName);
+
+ HubCommunicationThread ht = new HubCommunicationThread(conn, this, timeout);
+ ht.start();
+ hubThread = ht;
+
+ if (ht.getConnection().isConnected()) {
+ logger.debug("Communication thread to {} is up and running, we are online", hostName);
+ updateProperty(Thing.PROPERTY_SERIAL_NUMBER, serialNumber);
+ updateStatus(ThingStatus.ONLINE);
+ } else {
+ logger.debug("HubCommunicationThread is not connected anymore, setting to OFFLINE");
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+ "@text/message.bridge.connection.failed");
+ }
+ } catch (NoboCommunicationException commEx) {
+ logger.debug("HubCommunicationThread failed, exiting thread");
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, commEx.getMessage());
+ }
+ });
+ }
+
+ @Override
+ public void dispose() {
+ logger.debug("Disposing NoboHub '{}'", getThing().getUID().getId());
+
+ final NoboThingDiscoveryService discoveryService = this.discoveryService;
+ if (discoveryService != null) {
+ discoveryService.stopScan();
+ }
+
+ HubCommunicationThread ht = this.hubThread;
+ if (ht != null) {
+ logger.debug("Stopping communication thread");
+ ht.stopNow();
+ }
+ }
+
+ @Override
+ public void childHandlerInitialized(ThingHandler handler, Thing thing) {
+ logger.info("Adding thing: {}", thing.getLabel());
+ }
+
+ @Override
+ public void childHandlerDisposed(ThingHandler handler, Thing thing) {
+ logger.info("Disposing thing: {}", thing.getLabel());
+ }
+
+ private void onUpdate(Hub hub) {
+ logger.debug("Updating Hub: {}", hub.getName());
+ this.hub = hub;
+ OverridePlan activeOverridePlan = getOverride(hub.getActiveOverrideId());
+
+ if (null != activeOverridePlan) {
+ logger.debug("Updating Hub with ActiveOverrideId {} with Name {}", activeOverridePlan.getId(),
+ activeOverridePlan.getMode().name());
+
+ updateState(NoboHubBindingConstants.CHANNEL_HUB_ACTIVE_OVERRIDE_NAME,
+ StringType.valueOf(activeOverridePlan.getMode().name()));
+ }
+
+ // Update all zones to set online status and update profile name from weekProfileRegister
+ for (Zone zone : zoneRegister.values()) {
+ refreshZone(zone);
+ }
+
+ Map properties = editProperties();
+ properties.put(PROPERTY_HOSTNAME, hub.getName());
+ properties.put(Thing.PROPERTY_SERIAL_NUMBER, hub.getSerialNumber().toString());
+ properties.put(PROPERTY_SOFTWARE_VERSION, hub.getSoftwareVersion());
+ properties.put(Thing.PROPERTY_HARDWARE_VERSION, hub.getHardwareVersion());
+ properties.put(PROPERTY_PRODUCTION_DATE, hub.getProductionDate());
+ updateProperties(properties);
+ }
+
+ public void receivedData(@Nullable String line) {
+ try {
+ parseLine(line);
+ } catch (NoboDataException nde) {
+ logger.debug("Failed parsing line '{}': {}", line, nde.getMessage());
+ }
+ }
+
+ private void parseLine(@Nullable String line) throws NoboDataException {
+ if (null == line) {
+ return;
+ }
+
+ NoboThingDiscoveryService ds = this.discoveryService;
+ if (line.startsWith("H01")) {
+ Zone zone = Zone.fromH01(line);
+ zoneRegister.put(zone);
+ if (null != ds) {
+ ds.detectZones(zoneRegister.values());
+ }
+ } else if (line.startsWith("H02")) {
+ Component component = Component.fromH02(line);
+ componentRegister.put(component);
+ if (null != ds) {
+ ds.detectComponents(componentRegister.values());
+ }
+ } else if (line.startsWith("H03")) {
+ WeekProfile weekProfile = WeekProfile.fromH03(line);
+ weekProfileRegister.put(weekProfile);
+ } else if (line.startsWith("H04")) {
+ OverridePlan overridePlan = OverridePlan.fromH04(line);
+ overrideRegister.put(overridePlan);
+ } else if (line.startsWith("H05")) {
+ Hub hub = Hub.fromH05(line);
+ onUpdate(hub);
+ } else if (line.startsWith("S00")) {
+ Zone zone = Zone.fromH01(line);
+ zoneRegister.remove(zone.getId());
+ } else if (line.startsWith("S01")) {
+ Component component = Component.fromH02(line);
+ componentRegister.remove(component.getSerialNumber());
+ } else if (line.startsWith("S02")) {
+ WeekProfile weekProfile = WeekProfile.fromH03(line);
+ weekProfileRegister.remove(weekProfile.getId());
+ } else if (line.startsWith("S03")) {
+ OverridePlan overridePlan = OverridePlan.fromH04(line);
+ overrideRegister.remove(overridePlan.getId());
+ } else if (line.startsWith("B00")) {
+ Zone zone = Zone.fromH01(line);
+ zoneRegister.put(zone);
+ if (null != ds) {
+ ds.detectZones(zoneRegister.values());
+ }
+ } else if (line.startsWith("B01")) {
+ Component component = Component.fromH02(line);
+ componentRegister.put(component);
+ if (null != ds) {
+ ds.detectComponents(componentRegister.values());
+ }
+ } else if (line.startsWith("B02")) {
+ WeekProfile weekProfile = WeekProfile.fromH03(line);
+ weekProfileRegister.put(weekProfile);
+ } else if (line.startsWith("B03")) {
+ OverridePlan overridePlan = OverridePlan.fromH04(line);
+ overrideRegister.put(overridePlan);
+ } else if (line.startsWith("V00")) {
+ Zone zone = Zone.fromH01(line);
+ zoneRegister.put(zone);
+ refreshZone(zone);
+ } else if (line.startsWith("V01")) {
+ Component component = Component.fromH02(line);
+ componentRegister.put(component);
+ refreshComponent(component);
+ } else if (line.startsWith("V02")) {
+ WeekProfile weekProfile = WeekProfile.fromH03(line);
+ weekProfileRegister.put(weekProfile);
+ } else if (line.startsWith("V03")) {
+ Hub hub = Hub.fromH05(line);
+ onUpdate(hub);
+ } else if (line.startsWith("Y02")) {
+ Temperature temp = Temperature.fromY02(line);
+ Component component = getComponent(temp.getSerialNumber());
+ if (null != component) {
+ component.setTemperature(temp.getTemperature());
+ refreshComponent(component);
+ int zoneId = component.getTemperatureSensorForZoneId();
+ if (zoneId >= 0) {
+ Zone zone = getZone(zoneId);
+ if (null != zone) {
+ zone.setTemperature(temp.getTemperature());
+ refreshZone(zone);
+ }
+ }
+ }
+ } else if (line.startsWith("E00")) {
+ logger.debug("Error from Hub: {}", line);
+ } else {
+ // HANDSHAKE: Basic part of keepalive
+ // V06: Encryption key
+ // H00: contains no information
+ if (!line.startsWith("HANDSHAKE") && !line.startsWith("V06") && !line.startsWith("H00")) {
+ logger.info("Unknown information from Hub: '{}}'", line);
+ }
+ }
+ }
+
+ public @Nullable Zone getZone(Integer id) {
+ return zoneRegister.get(id);
+ }
+
+ public @Nullable WeekProfile getWeekProfile(Integer id) {
+ return weekProfileRegister.get(id);
+ }
+
+ public @Nullable Component getComponent(SerialNumber serialNumber) {
+ return componentRegister.get(serialNumber);
+ }
+
+ public @Nullable OverridePlan getOverride(Integer id) {
+ return overrideRegister.get(id);
+ }
+
+ public void sendCommand(String command) {
+ @Nullable
+ HubCommunicationThread ht = this.hubThread;
+ if (ht != null) {
+ HubConnection conn = ht.getConnection();
+ conn.sendCommand(command);
+ }
+ }
+
+ private void refreshZone(Zone zone) {
+ this.getThing().getThings().forEach(thing -> {
+ if (thing.getHandler() instanceof ZoneHandler) {
+ ZoneHandler handler = (ZoneHandler) thing.getHandler();
+ if (handler != null && handler.getZoneId() == zone.getId()) {
+ handler.onUpdate(zone);
+ }
+ }
+ });
+ }
+
+ private void refreshComponent(Component component) {
+ this.getThing().getThings().forEach(thing -> {
+ if (thing.getHandler() instanceof ComponentHandler) {
+ ComponentHandler handler = (ComponentHandler) thing.getHandler();
+ if (handler != null) {
+ SerialNumber handlerSerial = handler.getSerialNumber();
+ if (handlerSerial != null && component.getSerialNumber().equals(handlerSerial)) {
+ handler.onUpdate(component);
+ }
+ }
+ }
+ });
+ }
+
+ public void startScan() {
+ try {
+ @Nullable
+ HubCommunicationThread ht = this.hubThread;
+ if (ht != null) {
+ ht.getConnection().refreshAll();
+ }
+ } catch (NoboCommunicationException noboEx) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+ "@text/message.bridge.status.failed [\"" + noboEx.getMessage() + "\"]");
+ }
+ }
+
+ public void setDicsoveryService(NoboThingDiscoveryService discoveryService) {
+ this.discoveryService = discoveryService;
+ }
+
+ public Collection getWeekProfiles() {
+ return weekProfileRegister.values();
+ }
+
+ public void setStatusInfo(ThingStatus status, ThingStatusDetail statusDetail, @Nullable String description) {
+ updateStatus(status, statusDetail, description);
+ }
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/NoboHubConfiguration.java b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/NoboHubConfiguration.java
new file mode 100644
index 000000000..b5126b9e5
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/NoboHubConfiguration.java
@@ -0,0 +1,43 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * The {@link NoboHubConfiguration} class contains fields mapping thing configuration parameters.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+public class NoboHubConfiguration {
+
+ /**
+ * Serial number of Nobø Hub.
+ */
+ @Nullable
+ public String serialNumber;
+
+ /**
+ * Host address of Nobø Hub.
+ */
+ @Nullable
+ public String hostName;
+
+ /**
+ * Polling interval (seconds)
+ */
+ public int pollingInterval;
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/NoboHubHandlerFactory.java b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/NoboHubHandlerFactory.java
new file mode 100644
index 000000000..f5baaa233
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/NoboHubHandlerFactory.java
@@ -0,0 +1,132 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal;
+
+import static org.openhab.binding.nobohub.internal.NoboHubBindingConstants.SUPPORTED_THING_TYPES_UIDS;
+import static org.openhab.binding.nobohub.internal.NoboHubBindingConstants.THING_TYPE_COMPONENT;
+import static org.openhab.binding.nobohub.internal.NoboHubBindingConstants.THING_TYPE_HUB;
+import static org.openhab.binding.nobohub.internal.NoboHubBindingConstants.THING_TYPE_ZONE;
+
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Hashtable;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.nobohub.internal.discovery.NoboThingDiscoveryService;
+import org.openhab.core.config.discovery.DiscoveryService;
+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.framework.ServiceRegistration;
+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 NoboHubHandlerFactory} is responsible for creating things and thing
+ * handlers.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+@Component(configurationPid = "binding.nobohub", service = ThingHandlerFactory.class)
+public class NoboHubHandlerFactory extends BaseThingHandlerFactory {
+
+ private final Logger logger = LoggerFactory.getLogger(NoboHubHandlerFactory.class);
+ private final Map> discoveryServiceRegs = new HashMap<>();
+ public static final Set DISCOVERABLE_DEVICE_TYPES_UIDS = new HashSet<>(List.of(THING_TYPE_HUB));
+ private @NonNullByDefault({}) WeekProfileStateDescriptionOptionsProvider stateDescriptionOptionsProvider;
+
+ private final NoboHubTranslationProvider i18nProvider;
+
+ @Activate
+ public NoboHubHandlerFactory(
+ final @Reference WeekProfileStateDescriptionOptionsProvider stateDescriptionOptionsProvider,
+ final @Reference NoboHubTranslationProvider i18nProvider) {
+ this.stateDescriptionOptionsProvider = stateDescriptionOptionsProvider;
+ this.i18nProvider = i18nProvider;
+ }
+
+ @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_HUB.equals(thingTypeUID)) {
+ NoboHubBridgeHandler handler = new NoboHubBridgeHandler((Bridge) thing);
+ registerDiscoveryService(handler);
+ return handler;
+ } else if (THING_TYPE_ZONE.equals(thingTypeUID)) {
+ logger.debug("Setting WeekProfileStateDescriptionOptionsProvider for: {}", thing.getLabel());
+ return new ZoneHandler(thing, i18nProvider, stateDescriptionOptionsProvider);
+ } else if (THING_TYPE_COMPONENT.equals(thingTypeUID)) {
+ return new ComponentHandler(thing, i18nProvider);
+ }
+
+ return null;
+ }
+
+ @Override
+ protected void removeHandler(ThingHandler thingHandler) {
+ if (thingHandler instanceof NoboHubBridgeHandler) {
+ unregisterDiscoveryService((NoboHubBridgeHandler) thingHandler);
+ }
+ }
+
+ private synchronized void registerDiscoveryService(NoboHubBridgeHandler bridgeHandler) {
+ NoboThingDiscoveryService discoveryService = new NoboThingDiscoveryService(bridgeHandler);
+ bridgeHandler.setDicsoveryService(discoveryService);
+ this.discoveryServiceRegs.put(bridgeHandler.getThing().getThingTypeUID(), getBundleContext()
+ .registerService(DiscoveryService.class.getName(), discoveryService, new Hashtable<>()));
+ }
+
+ private synchronized void unregisterDiscoveryService(NoboHubBridgeHandler bridgeHandler) {
+ try {
+ ServiceRegistration> serviceReg = this.discoveryServiceRegs
+ .remove(bridgeHandler.getThing().getThingTypeUID());
+ if (null != serviceReg) {
+ NoboThingDiscoveryService service = (NoboThingDiscoveryService) getBundleContext()
+ .getService(serviceReg.getReference());
+ serviceReg.unregister();
+ if (null != service) {
+ service.deactivate();
+ }
+ }
+ } catch (IllegalArgumentException iae) {
+ logger.debug("Failed to unregister service", iae);
+ }
+ }
+
+ @Reference
+ protected void setDynamicStateDescriptionProvider(WeekProfileStateDescriptionOptionsProvider provider) {
+ this.stateDescriptionOptionsProvider = provider;
+ }
+
+ protected void unsetDynamicStateDescriptionProvider(WeekProfileStateDescriptionOptionsProvider provider) {
+ this.stateDescriptionOptionsProvider = null;
+ }
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/NoboHubTranslationProvider.java b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/NoboHubTranslationProvider.java
new file mode 100644
index 000000000..7066e51f5
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/NoboHubTranslationProvider.java
@@ -0,0 +1,66 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal;
+
+import java.util.Locale;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.i18n.LocaleProvider;
+import org.openhab.core.i18n.TranslationProvider;
+import org.osgi.framework.Bundle;
+import org.osgi.framework.FrameworkUtil;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+
+/**
+ * This class provides translated texts
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+@Component(service = NoboHubTranslationProvider.class)
+public class NoboHubTranslationProvider {
+
+ private final Bundle bundle;
+ private final TranslationProvider i18nProvider;
+ private final LocaleProvider localeProvider;
+
+ @Activate
+ public NoboHubTranslationProvider(@Reference TranslationProvider i18nProvider,
+ @Reference LocaleProvider localeProvider) {
+ this.bundle = FrameworkUtil.getBundle(this.getClass());
+ this.i18nProvider = i18nProvider;
+ this.localeProvider = localeProvider;
+ }
+
+ public NoboHubTranslationProvider(final NoboHubTranslationProvider other) {
+ this.bundle = other.bundle;
+ this.i18nProvider = other.i18nProvider;
+ this.localeProvider = other.localeProvider;
+ }
+
+ public String getText(String key, @Nullable Object... arguments) {
+ Locale locale = localeProvider.getLocale();
+ String message = i18nProvider.getText(bundle, key, this.getDefaultText(key), locale, arguments);
+ if (message != null) {
+ return message;
+ }
+ return key;
+ }
+
+ public @Nullable String getDefaultText(String key) {
+ return i18nProvider.getText(bundle, key, key, Locale.ENGLISH);
+ }
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/WeekProfileStateDescriptionOptionsProvider.java b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/WeekProfileStateDescriptionOptionsProvider.java
new file mode 100644
index 000000000..f2d7a09f7
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/WeekProfileStateDescriptionOptionsProvider.java
@@ -0,0 +1,42 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.events.EventPublisher;
+import org.openhab.core.thing.binding.BaseDynamicStateDescriptionProvider;
+import org.openhab.core.thing.i18n.ChannelTypeI18nLocalizationService;
+import org.openhab.core.thing.link.ItemChannelLinkRegistry;
+import org.openhab.core.thing.type.DynamicStateDescriptionProvider;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+
+/**
+ * Dynamic provider of week profile state options.
+ *
+ * @author Espen Fossen - Initial contribution
+ */
+@Component(service = { DynamicStateDescriptionProvider.class, WeekProfileStateDescriptionOptionsProvider.class })
+@NonNullByDefault
+public class WeekProfileStateDescriptionOptionsProvider extends BaseDynamicStateDescriptionProvider {
+
+ @Activate
+ public WeekProfileStateDescriptionOptionsProvider(final @Reference EventPublisher eventPublisher, //
+ final @Reference ItemChannelLinkRegistry itemChannelLinkRegistry, //
+ final @Reference ChannelTypeI18nLocalizationService channelTypeI18nLocalizationService) {
+ this.eventPublisher = eventPublisher;
+ this.itemChannelLinkRegistry = itemChannelLinkRegistry;
+ this.channelTypeI18nLocalizationService = channelTypeI18nLocalizationService;
+ }
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/ZoneConfiguration.java b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/ZoneConfiguration.java
new file mode 100644
index 000000000..a3a85d146
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/ZoneConfiguration.java
@@ -0,0 +1,29 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link ZoneConfiguration} class contains fields mapping thing configuration parameters.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ */
+@NonNullByDefault
+public class ZoneConfiguration {
+
+ /**
+ * Id of the zone
+ */
+ public int id;
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/ZoneHandler.java b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/ZoneHandler.java
new file mode 100644
index 000000000..38eebf6f6
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/ZoneHandler.java
@@ -0,0 +1,235 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal;
+
+import static org.openhab.binding.nobohub.internal.NoboHubBindingConstants.CHANNEL_ZONE_ACTIVE_WEEK_PROFILE;
+import static org.openhab.binding.nobohub.internal.NoboHubBindingConstants.CHANNEL_ZONE_ACTIVE_WEEK_PROFILE_NAME;
+import static org.openhab.binding.nobohub.internal.NoboHubBindingConstants.CHANNEL_ZONE_CALCULATED_WEEK_PROFILE_STATUS;
+import static org.openhab.binding.nobohub.internal.NoboHubBindingConstants.CHANNEL_ZONE_COMFORT_TEMPERATURE;
+import static org.openhab.binding.nobohub.internal.NoboHubBindingConstants.CHANNEL_ZONE_CURRENT_TEMPERATURE;
+import static org.openhab.binding.nobohub.internal.NoboHubBindingConstants.CHANNEL_ZONE_ECO_TEMPERATURE;
+import static org.openhab.binding.nobohub.internal.NoboHubBindingConstants.PROPERTY_HOSTNAME;
+import static org.openhab.binding.nobohub.internal.NoboHubBindingConstants.PROPERTY_ZONE_ID;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import javax.measure.quantity.Temperature;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.nobohub.internal.model.NoboDataException;
+import org.openhab.binding.nobohub.internal.model.WeekProfile;
+import org.openhab.binding.nobohub.internal.model.WeekProfileStatus;
+import org.openhab.binding.nobohub.internal.model.Zone;
+import org.openhab.core.library.types.DecimalType;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.library.unit.SIUnits;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.binding.BaseThingHandler;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.RefreshType;
+import org.openhab.core.types.StateOption;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Shows information about a named Zone in the Nobø Hub.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ */
+@NonNullByDefault
+public class ZoneHandler extends BaseThingHandler {
+
+ private final Logger logger = LoggerFactory.getLogger(ZoneHandler.class);
+
+ private final WeekProfileStateDescriptionOptionsProvider weekProfileStateDescriptionOptionsProvider;
+
+ private final NoboHubTranslationProvider messages;
+
+ protected int id;
+
+ public ZoneHandler(Thing thing, NoboHubTranslationProvider messages,
+ WeekProfileStateDescriptionOptionsProvider weekProfileStateDescriptionOptionsProvider) {
+ super(thing);
+ this.messages = messages;
+ this.weekProfileStateDescriptionOptionsProvider = weekProfileStateDescriptionOptionsProvider;
+ }
+
+ public void onUpdate(Zone zone) {
+ logger.debug("Updating zone: {}", zone.getName());
+ updateStatus(ThingStatus.ONLINE);
+
+ QuantityType comfortTemperature = new QuantityType<>(zone.getComfortTemperature(),
+ SIUnits.CELSIUS);
+ updateState(CHANNEL_ZONE_COMFORT_TEMPERATURE, comfortTemperature);
+ QuantityType ecoTemperature = new QuantityType<>(zone.getEcoTemperature(), SIUnits.CELSIUS);
+ updateState(CHANNEL_ZONE_ECO_TEMPERATURE, ecoTemperature);
+
+ Double temp = zone.getTemperature();
+ if (temp != null && !Double.isNaN(temp)) {
+ QuantityType currentTemperature = new QuantityType<>(temp, SIUnits.CELSIUS);
+ updateState(CHANNEL_ZONE_CURRENT_TEMPERATURE, currentTemperature);
+ }
+
+ int activeWeekProfileId = zone.getActiveWeekProfileId();
+ Bridge noboHub = getBridge();
+ if (null != noboHub) {
+ logger.debug("Updating zone: {} at hub bridge: {}", zone.getName(),
+ noboHub.getStatusInfo().getStatus().name());
+ NoboHubBridgeHandler hubHandler = (NoboHubBridgeHandler) noboHub.getHandler();
+ if (hubHandler != null) {
+ WeekProfile weekProfile = hubHandler.getWeekProfile(activeWeekProfileId);
+ if (null != weekProfile) {
+ updateState(CHANNEL_ZONE_ACTIVE_WEEK_PROFILE_NAME, StringType.valueOf(weekProfile.getName()));
+ updateState(CHANNEL_ZONE_ACTIVE_WEEK_PROFILE,
+ DecimalType.valueOf(String.valueOf(weekProfile.getId())));
+ try {
+ WeekProfileStatus weekProfileStatus = weekProfile.getStatusAt(LocalDateTime.now());
+ updateState(CHANNEL_ZONE_CALCULATED_WEEK_PROFILE_STATUS,
+ StringType.valueOf(weekProfileStatus.name()));
+ } catch (NoboDataException nde) {
+ logger.debug("Failed getting current week profile status", nde);
+ }
+ }
+
+ List options = new ArrayList<>();
+ logger.debug("Updating week profile state description options for zone {}.", zone.getName());
+ for (WeekProfile wp : hubHandler.getWeekProfiles()) {
+ options.add(new StateOption(String.valueOf(wp.getId()), wp.getName()));
+ }
+ logger.debug("State options count: {}. First: {}", options.size(),
+ (!options.isEmpty()) ? options.get(0) : 0);
+ weekProfileStateDescriptionOptionsProvider.setStateOptions(
+ new ChannelUID(getThing().getUID(), CHANNEL_ZONE_ACTIVE_WEEK_PROFILE), options);
+ }
+ }
+
+ Map properties = editProperties();
+ properties.put(PROPERTY_HOSTNAME, zone.getName());
+ properties.put(PROPERTY_ZONE_ID, Integer.toString(zone.getId()));
+ updateProperties(properties);
+ }
+
+ @Override
+ public void initialize() {
+ this.id = getConfigAs(ZoneConfiguration.class).id;
+ updateStatus(ThingStatus.ONLINE);
+ }
+
+ @Override
+ public void handleCommand(ChannelUID channelUID, Command command) {
+ if (command instanceof RefreshType) {
+ logger.debug("Refreshing channel {}", channelUID);
+
+ Zone zone = getZone();
+ if (null == zone) {
+ logger.debug("Could not find Zone with id {} for channel {}", id, channelUID);
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.GONE,
+ messages.getText("message.zone.notfound", id, channelUID));
+ } else {
+ onUpdate(zone);
+ Bridge noboHub = getBridge();
+ if (null != noboHub) {
+ NoboHubBridgeHandler hubHandler = (NoboHubBridgeHandler) noboHub.getHandler();
+ if (null != hubHandler) {
+ WeekProfile weekProfile = hubHandler.getWeekProfile(zone.getActiveWeekProfileId());
+ if (null != weekProfile) {
+ String weekProfileName = weekProfile.getName();
+ StringType weekProfileValue = StringType.valueOf(weekProfileName);
+ updateState(CHANNEL_ZONE_ACTIVE_WEEK_PROFILE_NAME, weekProfileValue);
+ }
+ }
+ }
+ }
+
+ return;
+ }
+
+ if (CHANNEL_ZONE_COMFORT_TEMPERATURE.equals(channelUID.getId())) {
+ Zone zone = getZone();
+ if (zone != null) {
+ if (command instanceof DecimalType) {
+ DecimalType comfortTemp = (DecimalType) command;
+ logger.debug("Set comfort temp for zone {} to {}", zone.getName(), comfortTemp.doubleValue());
+ zone.setComfortTemperature(comfortTemp.intValue());
+ sendCommand(zone.generateCommandString("U00"));
+ }
+ }
+
+ return;
+ }
+
+ if (CHANNEL_ZONE_ECO_TEMPERATURE.equals(channelUID.getId())) {
+ Zone zone = getZone();
+ if (zone != null) {
+ if (command instanceof DecimalType) {
+ DecimalType ecoTemp = (DecimalType) command;
+ logger.debug("Set eco temp for zone {} to {}", zone.getName(), ecoTemp.doubleValue());
+ zone.setEcoTemperature(ecoTemp.intValue());
+ sendCommand(zone.generateCommandString("U00"));
+ }
+ }
+ return;
+ }
+
+ if (CHANNEL_ZONE_ACTIVE_WEEK_PROFILE.equals(channelUID.getId())) {
+ Zone zone = getZone();
+ if (zone != null) {
+ if (command instanceof DecimalType) {
+ DecimalType weekProfileId = (DecimalType) command;
+ logger.debug("Set week profile for zone {} to {}", zone.getName(), weekProfileId);
+ zone.setWeekProfile(weekProfileId.intValue());
+ sendCommand(zone.generateCommandString("U00"));
+ }
+ }
+
+ return;
+ }
+
+ logger.debug("Unhandled zone command {}: {}", channelUID.getId(), command);
+ }
+
+ public @Nullable Integer getZoneId() {
+ return id;
+ }
+
+ private void sendCommand(String command) {
+ Bridge noboHub = getBridge();
+ if (null != noboHub) {
+ NoboHubBridgeHandler hubHandler = (NoboHubBridgeHandler) noboHub.getHandler();
+ if (null != hubHandler) {
+ hubHandler.sendCommand(command);
+ }
+ }
+ }
+
+ private @Nullable Zone getZone() {
+ Bridge noboHub = getBridge();
+ if (null != noboHub) {
+ NoboHubBridgeHandler hubHandler = (NoboHubBridgeHandler) noboHub.getHandler();
+ if (null != hubHandler) {
+ return hubHandler.getZone(id);
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/connection/HubCommunicationThread.java b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/connection/HubCommunicationThread.java
new file mode 100644
index 000000000..a562e7891
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/connection/HubCommunicationThread.java
@@ -0,0 +1,167 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal.connection;
+
+import java.time.Duration;
+import java.time.Instant;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.nobohub.internal.NoboHubBindingConstants;
+import org.openhab.binding.nobohub.internal.NoboHubBridgeHandler;
+import org.openhab.binding.nobohub.internal.model.NoboCommunicationException;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Thread that reads from the Nobø Hub and sends HANDSHAKEs to keep the connection open.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ */
+@NonNullByDefault
+public class HubCommunicationThread extends Thread {
+
+ private enum HubCommunicationThreadState {
+ STARTING(null, null, ""),
+ CONNECTED(ThingStatus.ONLINE, ThingStatusDetail.NONE, ""),
+ DISCONNECTED(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/message.bridge.status.failed"),
+ STOPPED(null, null, "");
+
+ private final @Nullable ThingStatus status;
+ private final @Nullable ThingStatusDetail statusDetail;
+ private final String errorMessage;
+
+ HubCommunicationThreadState(@Nullable ThingStatus status, @Nullable ThingStatusDetail statusDetail,
+ String errorMessage) {
+ this.status = status;
+ this.statusDetail = statusDetail;
+ this.errorMessage = errorMessage;
+ }
+
+ public @Nullable ThingStatus getThingStatus() {
+ return status;
+ }
+
+ public @Nullable ThingStatusDetail getThingStatusDetail() {
+ return statusDetail;
+ }
+
+ public String getErrorMessage() {
+ return errorMessage;
+ }
+ }
+
+ private final Logger logger = LoggerFactory.getLogger(HubCommunicationThread.class);
+
+ private final HubConnection hubConnection;
+ private final NoboHubBridgeHandler hubHandler;
+ private final Duration timeout;
+ private Instant lastTimeFullScan;
+
+ private volatile boolean stopped = false;
+ private HubCommunicationThreadState currentState = HubCommunicationThreadState.STARTING;
+
+ public HubCommunicationThread(HubConnection hubConnection, NoboHubBridgeHandler hubHandler, Duration timeout) {
+ this.hubConnection = hubConnection;
+ this.hubHandler = hubHandler;
+ this.timeout = timeout;
+ this.lastTimeFullScan = Instant.now();
+ }
+
+ public void stopNow() {
+ stopped = true;
+ }
+
+ @Override
+ public void run() {
+ while (!stopped) {
+ switch (currentState) {
+ case STARTING:
+ try {
+ hubConnection.refreshAll();
+ lastTimeFullScan = Instant.now();
+ setNextState(HubCommunicationThreadState.CONNECTED);
+ } catch (NoboCommunicationException nce) {
+ logger.debug("Communication error with Hub", nce);
+ setNextState(HubCommunicationThreadState.DISCONNECTED);
+ }
+ break;
+
+ case CONNECTED:
+ try {
+ if (hubConnection.hasData()) {
+ hubConnection.processReads(timeout);
+ }
+
+ if (Instant.now()
+ .isAfter(lastTimeFullScan.plus(NoboHubBindingConstants.TIME_BETWEEN_FULL_SCANS))) {
+ hubConnection.refreshAll();
+ lastTimeFullScan = Instant.now();
+ } else {
+ hubConnection.handshake();
+ }
+
+ hubConnection.processReads(timeout);
+ } catch (NoboCommunicationException nce) {
+ logger.debug("Communication error with Hub", nce);
+ setNextState(HubCommunicationThreadState.DISCONNECTED);
+ }
+ break;
+
+ case DISCONNECTED:
+ try {
+ Thread.sleep(NoboHubBindingConstants.TIME_BETWEEN_RETRIES_ON_ERROR.toMillis());
+ try {
+ logger.debug("Trying to do a hard reconnect");
+ hubConnection.hardReconnect();
+ setNextState(HubCommunicationThreadState.CONNECTED);
+ } catch (NoboCommunicationException nce2) {
+ logger.debug("Failed to reconnect connection", nce2);
+ }
+ } catch (InterruptedException ie) {
+ logger.debug("Interrupted from sleep after error");
+ Thread.currentThread().interrupt();
+ }
+ break;
+
+ case STOPPED:
+ break;
+ }
+ }
+
+ if (stopped) {
+ logger.debug("HubCommunicationThread is stopped, disconnecting from Hub");
+ setNextState(HubCommunicationThreadState.STOPPED);
+ try {
+ hubConnection.disconnect();
+ } catch (NoboCommunicationException nce) {
+ logger.debug("Error disconnecting from Hub", nce);
+ }
+ }
+ }
+
+ public HubConnection getConnection() {
+ return hubConnection;
+ }
+
+ private void setNextState(HubCommunicationThreadState newState) {
+ currentState = newState;
+ ThingStatus stateThingStatus = newState.getThingStatus();
+ ThingStatusDetail stateThingStatusDetail = newState.getThingStatusDetail();
+ if (null != stateThingStatus && null != stateThingStatusDetail) {
+ hubHandler.setStatusInfo(stateThingStatus, stateThingStatusDetail, newState.getErrorMessage());
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/connection/HubConnection.java b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/connection/HubConnection.java
new file mode 100644
index 000000000..b0bdcc845
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/connection/HubConnection.java
@@ -0,0 +1,270 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal.connection;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.PrintWriter;
+import java.net.InetAddress;
+import java.net.Socket;
+import java.net.SocketException;
+import java.net.SocketTimeoutException;
+import java.time.Duration;
+import java.time.LocalDateTime;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.nobohub.internal.Helpers;
+import org.openhab.binding.nobohub.internal.NoboHubBindingConstants;
+import org.openhab.binding.nobohub.internal.NoboHubBridgeHandler;
+import org.openhab.binding.nobohub.internal.model.Hub;
+import org.openhab.binding.nobohub.internal.model.NoboCommunicationException;
+import org.openhab.binding.nobohub.internal.model.NoboDataException;
+import org.openhab.binding.nobohub.internal.model.OverrideMode;
+import org.openhab.binding.nobohub.internal.model.OverridePlan;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Connection to the Nobø Hub (Socket wrapper).
+ *
+ * @author Jørgen Austvik - Initial contribution
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+public class HubConnection {
+
+ private final Logger logger = LoggerFactory.getLogger(HubConnection.class);
+
+ private final String hostName;
+ private final NoboHubBridgeHandler hubHandler;
+ private final String serialNumber;
+
+ private @Nullable InetAddress host;
+ private @Nullable Socket hubConnection;
+ private @Nullable PrintWriter out;
+ private @Nullable BufferedReader in;
+
+ public HubConnection(String hostName, String serialNumber, NoboHubBridgeHandler hubHandler)
+ throws NoboCommunicationException {
+ this.hostName = hostName;
+ this.serialNumber = serialNumber;
+ this.hubHandler = hubHandler;
+ }
+
+ public void connect() throws NoboCommunicationException {
+ connectSocket();
+
+ String hello = String.format("HELLO %s %s %s\r", NoboHubBindingConstants.API_VERSION, serialNumber,
+ getDateString());
+ write(hello);
+
+ String helloRes = readLine();
+ if (null == helloRes || !helloRes.startsWith("HELLO")) {
+ if (helloRes != null && helloRes.startsWith("REJECT")) {
+ String[] reject = helloRes.split(" ", 2);
+ throw new NoboCommunicationException(String.format("Hub rejects us with reason %s: %s", reject[1],
+ NoboHubBindingConstants.REJECT_REASONS.get(reject[1])));
+ } else {
+ throw new NoboCommunicationException("Hub rejects us with unknown reason");
+ }
+ }
+
+ write("HANDSHAKE\r");
+
+ String handshakeRes = readLine();
+ if (null == handshakeRes || !handshakeRes.startsWith("HANDSHAKE")) {
+ throw new NoboCommunicationException("Hub rejects handshake");
+ }
+
+ refreshAllNoReconnect();
+ }
+
+ public void handshake() throws NoboCommunicationException {
+ if (!isConnected()) {
+ connect();
+ } else {
+ write("HANDSHAKE\r");
+ }
+ }
+
+ public void setOverride(Hub hub, OverrideMode nextMode) throws NoboDataException, NoboCommunicationException {
+ if (!isConnected()) {
+ connect();
+ }
+
+ OverridePlan overridePlan = OverridePlan.fromMode(nextMode, LocalDateTime.now());
+ sendCommand(overridePlan.generateCommandString("A03"));
+
+ String line = "";
+ while (line != null && !line.startsWith("B03")) {
+ line = readLine();
+ hubHandler.receivedData(line);
+ }
+
+ String l = line;
+ if (null != l) {
+ OverridePlan newOverridePlan = OverridePlan.fromH04(l);
+ hub.setActiveOverrideId(newOverridePlan.getId());
+ sendCommand(hub.generateCommandString("U03"));
+ }
+ }
+
+ public void refreshAll() throws NoboCommunicationException {
+ if (!isConnected()) {
+ connect();
+ } else {
+ refreshAllNoReconnect();
+ }
+ }
+
+ private void refreshAllNoReconnect() throws NoboCommunicationException {
+ write("G00\r");
+
+ String line = "";
+ while (line != null && !line.startsWith("H05")) {
+ line = readLine();
+ hubHandler.receivedData(line);
+ }
+ }
+
+ public boolean isConnected() {
+ Socket conn = this.hubConnection;
+ if (null != conn) {
+ return conn.isConnected();
+ }
+
+ return false;
+ }
+
+ public boolean hasData() throws NoboCommunicationException {
+ BufferedReader i = this.in;
+ if (null != i) {
+ try {
+ return i.ready();
+ } catch (IOException ioex) {
+ throw new NoboCommunicationException("Failed detecting if buffer has any data", ioex);
+ }
+ }
+
+ return false;
+ }
+
+ public void processReads(Duration timeout) throws NoboCommunicationException {
+ try {
+ Socket conn = this.hubConnection;
+ if (null == conn) {
+ throw new NoboCommunicationException("No connection to Hub");
+ }
+
+ logger.trace("Reading from Hub, waiting maximum {}", Helpers.formatDuration(timeout));
+ conn.setSoTimeout((int) timeout.toMillis());
+
+ try {
+ String line = readLine();
+ if (line != null && line.startsWith("HANDSHAKE")) {
+ line = readLine();
+ }
+
+ hubHandler.receivedData(line);
+ } catch (NoboCommunicationException nce) {
+ if (!(nce.getCause() instanceof SocketTimeoutException)) {
+ connectSocket();
+ }
+ }
+ } catch (SocketException se) {
+ throw new NoboCommunicationException("Failed setting read timeout", se);
+ }
+ }
+
+ private @Nullable String readLine() throws NoboCommunicationException {
+ BufferedReader reader = this.in;
+ try {
+ if (null != reader) {
+ String line = reader.readLine();
+ if (line != null) {
+ logger.trace("Reading raw data string from Nobø Hub: {}", line);
+ }
+ return line;
+ }
+ } catch (IOException ioex) {
+ throw new NoboCommunicationException("Failed reading from Nobø Hub", ioex);
+ }
+
+ return null;
+ }
+
+ public void sendCommand(String command) {
+ write(command);
+ }
+
+ private void write(String s) {
+ @Nullable
+ PrintWriter o = this.out;
+ if (null != o) {
+ logger.trace("Sending '{}'", s);
+ o.write(s);
+ o.flush();
+ }
+ }
+
+ private void connectSocket() throws NoboCommunicationException {
+ if (null == host) {
+ try {
+ host = InetAddress.getByName(hostName);
+ } catch (IOException ioex) {
+ throw new NoboCommunicationException(String.format("Failed to resolve IP address of %s", hostName),
+ ioex);
+ }
+ }
+ try {
+ Socket conn = new Socket(host, NoboHubBindingConstants.NOBO_HUB_TCP_PORT);
+ out = new PrintWriter(conn.getOutputStream(), true);
+ in = new BufferedReader(new InputStreamReader(conn.getInputStream()));
+ hubConnection = conn;
+ } catch (IOException ioex) {
+ throw new NoboCommunicationException(String.format("Failed connecting to Nobø Hub at %s", hostName), ioex);
+ }
+ }
+
+ public void disconnect() throws NoboCommunicationException {
+ try {
+ PrintWriter o = this.out;
+ if (o != null) {
+ o.close();
+ }
+
+ BufferedReader i = this.in;
+ if (i != null) {
+ i.close();
+ }
+
+ Socket conn = this.hubConnection;
+ if (conn != null) {
+ conn.close();
+ }
+ } catch (IOException ioex) {
+ throw new NoboCommunicationException("Error disconnecting from Hub", ioex);
+ }
+ }
+
+ public void hardReconnect() throws NoboCommunicationException {
+ disconnect();
+ connect();
+ }
+
+ private String getDateString() {
+ return LocalDateTime.now().format(NoboHubBindingConstants.DATE_FORMAT_SECONDS);
+ }
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/discovery/NoboHubDiscoveryService.java b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/discovery/NoboHubDiscoveryService.java
new file mode 100644
index 000000000..b19996348
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/discovery/NoboHubDiscoveryService.java
@@ -0,0 +1,163 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal.discovery;
+
+import static org.openhab.binding.nobohub.internal.NoboHubBindingConstants.NOBO_HUB_BROADCAST_ADDRESS;
+import static org.openhab.binding.nobohub.internal.NoboHubBindingConstants.NOBO_HUB_BROADCAST_PORT;
+import static org.openhab.binding.nobohub.internal.NoboHubBindingConstants.NOBO_HUB_MULTICAST_PORT;
+import static org.openhab.binding.nobohub.internal.NoboHubBindingConstants.PROPERTY_HOSTNAME;
+import static org.openhab.binding.nobohub.internal.NoboHubBindingConstants.PROPERTY_NAME;
+import static org.openhab.binding.nobohub.internal.NoboHubBindingConstants.PROPERTY_VENDOR_NAME;
+import static org.openhab.binding.nobohub.internal.NoboHubBindingConstants.THING_TYPE_HUB;
+import static org.openhab.binding.nobohub.internal.NoboHubHandlerFactory.DISCOVERABLE_DEVICE_TYPES_UIDS;
+
+import java.io.IOException;
+import java.net.DatagramPacket;
+import java.net.DatagramSocket;
+import java.net.InetAddress;
+import java.net.MulticastSocket;
+import java.time.Duration;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.nobohub.internal.NoboHubBridgeHandler;
+import org.openhab.core.config.discovery.AbstractDiscoveryService;
+import org.openhab.core.config.discovery.DiscoveryResult;
+import org.openhab.core.config.discovery.DiscoveryResultBuilder;
+import org.openhab.core.config.discovery.DiscoveryService;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingUID;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.openhab.core.thing.binding.ThingHandlerService;
+import org.osgi.service.component.annotations.Component;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This class identifies devices that are available on the Nobø hub and adds discovery results for them.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+@Component(service = DiscoveryService.class, immediate = true, configurationPid = "discovery.nobohub")
+public class NoboHubDiscoveryService extends AbstractDiscoveryService implements DiscoveryService, ThingHandlerService {
+ private final Logger logger = LoggerFactory.getLogger(NoboHubDiscoveryService.class);
+
+ private @NonNullByDefault({}) NoboHubBridgeHandler hubBridgeHandler;
+
+ public NoboHubDiscoveryService() {
+ super(DISCOVERABLE_DEVICE_TYPES_UIDS, 10, true);
+ }
+
+ @Override
+ protected void startScan() {
+ scheduler.execute(scanner);
+ }
+
+ @Override
+ protected synchronized void stopScan() {
+ super.stopScan();
+ removeOlderResults(getTimestampOfLastScan());
+ }
+
+ @Override
+ public void deactivate() {
+ removeOlderResults(new Date().getTime());
+ }
+
+ @Override
+ public void setThingHandler(ThingHandler thingHandler) {
+ if (thingHandler instanceof NoboHubBridgeHandler) {
+ this.hubBridgeHandler = (NoboHubBridgeHandler) thingHandler;
+ }
+ }
+
+ @Override
+ public @Nullable ThingHandler getThingHandler() {
+ return hubBridgeHandler;
+ }
+
+ private final Runnable scanner = new Runnable() {
+ @Override
+ public void run() {
+ boolean found = false;
+ logger.info("Detecting Glen Dimplex Nobø Hubs, trying Multicast");
+ try {
+ MulticastSocket socket = new MulticastSocket(NOBO_HUB_MULTICAST_PORT);
+ found = waitOnSocket(socket, "multicast");
+ } catch (IOException ioex) {
+ logger.error("Failed detecting Nobø Hub via multicast", ioex);
+ }
+
+ if (!found) {
+ logger.debug("Detecting Glen Dimplex Nobø Hubs, trying Broadcast");
+
+ try {
+ DatagramSocket socket = new DatagramSocket(NOBO_HUB_BROADCAST_PORT,
+ InetAddress.getByName(NOBO_HUB_BROADCAST_ADDRESS));
+ found = waitOnSocket(socket, "broadcast");
+ } catch (IOException ioex) {
+ logger.error("Failed detecting Nobø Hub via multicast, will try with Broadcast", ioex);
+ }
+ }
+ }
+
+ private boolean waitOnSocket(DatagramSocket socket, String type) throws IOException {
+ try (socket) {
+ socket.setBroadcast(true);
+
+ byte[] buffer = new byte[1024];
+ DatagramPacket data = new DatagramPacket(buffer, buffer.length);
+ String received = "";
+ while (!received.startsWith("__NOBOHUB__")) {
+ socket.setSoTimeout((int) Duration.ofSeconds(4).toMillis());
+ socket.receive(data);
+ received = new String(buffer, 0, data.getLength());
+ }
+
+ logger.debug("Hub detection using {}: Received: {} from {}", type, received, data.getAddress());
+
+ String[] parts = received.split("__", 3);
+ if (3 != parts.length) {
+ logger.debug("Data error, didn't contain three parts: '{}''", String.join("','", parts));
+ return false;
+ }
+
+ String serialNumberStart = parts[parts.length - 1];
+ addDevice(serialNumberStart, data.getAddress().getHostName());
+ return true;
+ }
+ }
+
+ private void addDevice(String serialNumberStart, String hostName) {
+ ThingUID bridge = new ThingUID(THING_TYPE_HUB, serialNumberStart);
+ String label = "Nobø Hub " + serialNumberStart;
+
+ Map properties = new HashMap<>(4);
+ properties.put(Thing.PROPERTY_SERIAL_NUMBER, serialNumberStart);
+ properties.put(PROPERTY_NAME, label);
+ properties.put(Thing.PROPERTY_VENDOR, PROPERTY_VENDOR_NAME);
+ properties.put(PROPERTY_HOSTNAME, hostName);
+
+ logger.debug("Adding device {} to inbox: {} {} at {}", bridge, label, serialNumberStart, hostName);
+ DiscoveryResult discoveryResult = DiscoveryResultBuilder.create(bridge).withLabel(label)
+ .withProperties(properties).withRepresentationProperty(Thing.PROPERTY_SERIAL_NUMBER).build();
+ thingDiscovered(discoveryResult);
+ }
+ };
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/discovery/NoboThingDiscoveryService.java b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/discovery/NoboThingDiscoveryService.java
new file mode 100644
index 000000000..58c27914a
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/discovery/NoboThingDiscoveryService.java
@@ -0,0 +1,161 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal.discovery;
+
+import static org.openhab.binding.nobohub.internal.NoboHubBindingConstants.AUTODISCOVERED_THING_TYPES_UIDS;
+import static org.openhab.binding.nobohub.internal.NoboHubBindingConstants.PROPERTY_MODEL;
+import static org.openhab.binding.nobohub.internal.NoboHubBindingConstants.PROPERTY_NAME;
+import static org.openhab.binding.nobohub.internal.NoboHubBindingConstants.PROPERTY_TEMPERATURE_SENSOR_FOR_ZONE;
+import static org.openhab.binding.nobohub.internal.NoboHubBindingConstants.PROPERTY_VENDOR_NAME;
+import static org.openhab.binding.nobohub.internal.NoboHubBindingConstants.PROPERTY_ZONE;
+import static org.openhab.binding.nobohub.internal.NoboHubBindingConstants.PROPERTY_ZONE_ID;
+import static org.openhab.binding.nobohub.internal.NoboHubBindingConstants.THING_TYPE_COMPONENT;
+import static org.openhab.binding.nobohub.internal.NoboHubBindingConstants.THING_TYPE_ZONE;
+
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.nobohub.internal.NoboHubBridgeHandler;
+import org.openhab.binding.nobohub.internal.model.Component;
+import org.openhab.binding.nobohub.internal.model.Zone;
+import org.openhab.core.config.discovery.AbstractDiscoveryService;
+import org.openhab.core.config.discovery.DiscoveryResult;
+import org.openhab.core.config.discovery.DiscoveryResultBuilder;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingUID;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This class identifies devices that are available on the Nobø hub and adds discovery results for them.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+public class NoboThingDiscoveryService extends AbstractDiscoveryService {
+ private final Logger logger = LoggerFactory.getLogger(NoboThingDiscoveryService.class);
+
+ private final NoboHubBridgeHandler bridgeHandler;
+
+ public NoboThingDiscoveryService(NoboHubBridgeHandler bridgeHandler) {
+ super(AUTODISCOVERED_THING_TYPES_UIDS, 10, true);
+ this.bridgeHandler = bridgeHandler;
+ }
+
+ @Override
+ protected void startScan() {
+ bridgeHandler.startScan();
+ }
+
+ @Override
+ public synchronized void stopScan() {
+ super.stopScan();
+ removeOlderResults(getTimestampOfLastScan());
+ }
+
+ @Override
+ public void deactivate() {
+ removeOlderResults(new Date().getTime());
+ }
+
+ public void detectZones(Collection zones) {
+ ThingUID bridge = bridgeHandler.getThing().getUID();
+ List things = bridgeHandler.getThing().getThings();
+
+ for (Zone zone : zones) {
+ ThingUID discoveredThingId = new ThingUID(THING_TYPE_ZONE, bridge, Integer.toString(zone.getId()));
+
+ boolean addDiscoveredZone = true;
+ for (Thing thing : things) {
+ if (thing.getUID().equals(discoveredThingId)) {
+ addDiscoveredZone = false;
+ }
+ }
+
+ if (addDiscoveredZone) {
+ String label = zone.getName();
+
+ Map properties = new HashMap<>(3);
+ properties.put(PROPERTY_ZONE_ID, Integer.toString(zone.getId()));
+ properties.put(PROPERTY_NAME, zone.getName());
+ properties.put(Thing.PROPERTY_VENDOR, PROPERTY_VENDOR_NAME);
+
+ logger.debug("Adding device {} to inbox", discoveredThingId);
+ DiscoveryResult discoveryResult = DiscoveryResultBuilder.create(discoveredThingId).withBridge(bridge)
+ .withLabel(label).withProperties(properties).withRepresentationProperty("id").build();
+ thingDiscovered(discoveryResult);
+ }
+ }
+ }
+
+ public void detectComponents(Collection components) {
+ ThingUID bridge = bridgeHandler.getThing().getUID();
+ List things = bridgeHandler.getThing().getThings();
+
+ for (Component component : components) {
+ ThingUID discoveredThingId = new ThingUID(THING_TYPE_COMPONENT, bridge,
+ component.getSerialNumber().toString());
+
+ boolean addDiscoveredComponent = true;
+ for (Thing thing : things) {
+ if (thing.getUID().equals(discoveredThingId)) {
+ addDiscoveredComponent = false;
+ }
+ }
+
+ if (addDiscoveredComponent) {
+ String label = component.getName();
+
+ Map properties = new HashMap<>(4);
+ properties.put(Thing.PROPERTY_SERIAL_NUMBER, component.getSerialNumber().toString());
+ properties.put(PROPERTY_NAME, component.getName());
+ properties.put(Thing.PROPERTY_VENDOR, PROPERTY_VENDOR_NAME);
+ properties.put(PROPERTY_MODEL, component.getSerialNumber().getComponentType());
+
+ String zoneName = getZoneName(component.getZoneId());
+ if (zoneName != null) {
+ properties.put(PROPERTY_ZONE, zoneName);
+ }
+
+ int zoneId = component.getTemperatureSensorForZoneId();
+ if (zoneId >= 0) {
+ String tempForZoneName = getZoneName(zoneId);
+ if (tempForZoneName != null) {
+ properties.put(PROPERTY_TEMPERATURE_SENSOR_FOR_ZONE, tempForZoneName);
+ }
+ }
+
+ logger.debug("Adding device {} to inbox", discoveredThingId);
+ DiscoveryResult discoveryResult = DiscoveryResultBuilder.create(discoveredThingId).withBridge(bridge)
+ .withLabel(label).withProperties(properties)
+ .withRepresentationProperty(Thing.PROPERTY_SERIAL_NUMBER).build();
+ thingDiscovered(discoveryResult);
+ }
+ }
+ }
+
+ private @Nullable String getZoneName(int zoneId) {
+ Zone zone = bridgeHandler.getZone(zoneId);
+ if (null == zone) {
+ return null;
+ }
+
+ return zone.getName();
+ }
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/Component.java b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/Component.java
new file mode 100644
index 000000000..ea3be8828
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/Component.java
@@ -0,0 +1,102 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal.model;
+
+import java.util.StringJoiner;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * A Component in the Nobø Hub can be a oven, a floor or a switch.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+public final class Component {
+
+ private final SerialNumber serialNumber;
+ private final String name;
+ private final boolean reverse;
+ private final int zoneId;
+ private final int temperatureSensorForZoneId;
+ private double temperature;
+
+ public Component(SerialNumber serialNumber, String name, boolean reverse, int zoneId,
+ int temperatureSensorForZoneId) {
+ this.serialNumber = serialNumber;
+ this.name = name;
+ this.reverse = reverse;
+ this.zoneId = zoneId;
+ this.temperatureSensorForZoneId = temperatureSensorForZoneId;
+ }
+
+ public static Component fromH02(String h02) throws NoboDataException {
+ String[] parts = h02.split(" ", 8);
+
+ if (parts.length != 8) {
+ throw new NoboDataException(
+ String.format("Unexpected number of parts from hub on H2 call: %d", parts.length));
+ }
+
+ SerialNumber serial = new SerialNumber(ModelHelper.toJavaString(parts[1]));
+ if (!serial.isWellFormed()) {
+ throw new NoboDataException(String.format("Illegal serial number: '%s'", serial));
+ }
+
+ return new Component(serial, ModelHelper.toJavaString(parts[3]), "1".equals(parts[4]),
+ Integer.parseInt(parts[5]), Integer.parseInt(parts[7]));
+ }
+
+ public String generateCommandString(final String command) {
+ StringJoiner joiner = new StringJoiner(" ");
+ joiner.add(command).add(ModelHelper.toHubString(serialNumber.toString()));
+
+ // Status not yet implemented in hub
+ joiner.add("0");
+
+ joiner.add(ModelHelper.toHubString(name)).add(reverse ? "1" : "0").add(Integer.toString(zoneId)).add("-1");
+
+ // Active Override ID not implemented in hub for components yet
+ joiner.add(Integer.toString(temperatureSensorForZoneId));
+ return joiner.toString();
+ }
+
+ public SerialNumber getSerialNumber() {
+ return serialNumber;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public boolean inReverse() {
+ return reverse;
+ }
+
+ public int getZoneId() {
+ return zoneId;
+ }
+
+ public int getTemperatureSensorForZoneId() {
+ return temperatureSensorForZoneId;
+ }
+
+ public double getTemperature() {
+ return temperature;
+ }
+
+ public void setTemperature(double temperature) {
+ this.temperature = temperature;
+ }
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/ComponentRegister.java b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/ComponentRegister.java
new file mode 100644
index 000000000..42b752e1a
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/ComponentRegister.java
@@ -0,0 +1,67 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal.model;
+
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+
+import javax.validation.constraints.NotNull;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * Stores a mapping between component ids and components that exists.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+public final class ComponentRegister {
+
+ private final @NotNull Map register = new HashMap();
+
+ /**
+ * Stores a new Component in the register. If a component exists with the same id, that value is overwritten.
+ *
+ * @param component The Component to store.
+ */
+ public void put(Component component) {
+ register.put(component.getSerialNumber(), component);
+ }
+
+ /**
+ * Removes a component from the registry.
+ *
+ * @param componentId The component to remove
+ * @return The component that is removed. Null if the component is not found.
+ */
+ public @Nullable Component remove(SerialNumber componentId) {
+ return register.remove(componentId);
+ }
+
+ /**
+ * Returns a component from the registry.
+ *
+ * @param componentId The id of the component to return.
+ * @return Returns the component, or null if it doesn't exist in the regestry.
+ */
+ public @Nullable Component get(SerialNumber componentId) {
+ return register.get(componentId);
+ }
+
+ public Collection values() {
+ return register.values();
+ }
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/Hub.java b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/Hub.java
new file mode 100644
index 000000000..659982e90
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/Hub.java
@@ -0,0 +1,104 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal.model;
+
+import java.time.Duration;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Contains information about the Hub we are communicating with.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ */
+@NonNullByDefault
+public class Hub {
+
+ private final SerialNumber serialNumber;
+
+ private final String name;
+
+ private int activeOverrideId;
+
+ private final int defaultAwayOverrideLength;
+
+ private final String softwareVersion;
+
+ private final String hardwareVersion;
+
+ private final String productionDate;
+
+ public Hub(SerialNumber serialNumber, String name, int defaultAwayOverrideLength, int activeOverrideId,
+ String softwareVersion, String hardwareVersion, String productionDate) {
+ this.serialNumber = serialNumber;
+ this.name = name;
+ this.defaultAwayOverrideLength = defaultAwayOverrideLength;
+ this.activeOverrideId = activeOverrideId;
+ this.softwareVersion = softwareVersion;
+ this.hardwareVersion = hardwareVersion;
+ this.productionDate = productionDate;
+ }
+
+ public static Hub fromH05(String h05) throws NoboDataException {
+ String parts[] = h05.split(" ", 8);
+
+ if (parts.length != 8) {
+ throw new NoboDataException(
+ String.format("Unexpected number of parts from hub on H5 call: %d", parts.length));
+ }
+
+ return new Hub(new SerialNumber(ModelHelper.toJavaString(parts[1])), ModelHelper.toJavaString(parts[2]),
+ Integer.parseInt(parts[3]), Integer.parseInt(parts[4]), ModelHelper.toJavaString(parts[5]),
+ ModelHelper.toJavaString(parts[6]), ModelHelper.toJavaString(parts[7]));
+ }
+
+ public String generateCommandString(final String command) {
+ return String.join(" ", command, serialNumber.toString(), ModelHelper.toHubString(name),
+ Integer.toString(defaultAwayOverrideLength), Integer.toString(activeOverrideId),
+ ModelHelper.toHubString(softwareVersion), ModelHelper.toHubString(hardwareVersion),
+ ModelHelper.toHubString(productionDate));
+ }
+
+ public SerialNumber getSerialNumber() {
+ return serialNumber;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public Duration getDefaultAwayOverrideLength() {
+ return Duration.ofMinutes(defaultAwayOverrideLength);
+ }
+
+ public int getActiveOverrideId() {
+ return activeOverrideId;
+ }
+
+ public void setActiveOverrideId(int id) {
+ activeOverrideId = id;
+ }
+
+ public String getSoftwareVersion() {
+ return softwareVersion;
+ }
+
+ public String getHardwareVersion() {
+ return hardwareVersion;
+ }
+
+ public String getProductionDate() {
+ return productionDate;
+ }
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/ModelHelper.java b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/ModelHelper.java
new file mode 100644
index 000000000..3d1f10fa4
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/ModelHelper.java
@@ -0,0 +1,82 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal.model;
+
+import java.time.DateTimeException;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeParseException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.nobohub.internal.NoboHubBindingConstants;
+
+/**
+ * Helper class for converting data to/from Nobø Hub.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+public final class ModelHelper {
+
+ /**
+ * Converts a String returned form Nobø hub to a normal Java string.
+ *
+ * @param noboString String where Char 160 (nobr space is used for space)
+ * @return String with normal spaces.
+ */
+ static String toJavaString(final String noboString) {
+ return noboString.replace((char) 160, ' ');
+ }
+
+ /**
+ * Converts a String in java to a string the Nobø hub can understand (fix spaces).
+ *
+ * @param javaString String to send to Nobø hub
+ * @return String with Nobø hub spaces
+ */
+ static String toHubString(final String javaString) {
+ return javaString.replace(' ', (char) 160);
+ }
+
+ /**
+ * Creates a Java date string from a date string returned from the Nobø Hub.
+ *
+ * @param noboDateString Date string from Nobø, like '202001221832' or '-1'
+ * @return Java date for the returned string (or null if -1 is returned)
+ */
+ @Nullable
+ static LocalDateTime toJavaDate(final String noboDateString) throws NoboDataException {
+ if ("-1".equals(noboDateString)) {
+ return null;
+ }
+
+ try {
+ return LocalDateTime.parse(noboDateString, NoboHubBindingConstants.DATE_FORMAT_MINUTES);
+ } catch (DateTimeParseException pe) {
+ throw new NoboDataException(String.format("Failed parsing string %s", noboDateString), pe);
+ }
+ }
+
+ static String toHubDateMinutes(final @Nullable LocalDateTime date) {
+ if (null == date) {
+ return "-1";
+ }
+
+ try {
+ return date.format(NoboHubBindingConstants.DATE_FORMAT_MINUTES);
+ } catch (DateTimeException dte) {
+ return "-1";
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/NoboCommunicationException.java b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/NoboCommunicationException.java
new file mode 100644
index 000000000..a437652b6
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/NoboCommunicationException.java
@@ -0,0 +1,34 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal.model;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Exception thrown when failing to communicate with the hub.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ */
+@NonNullByDefault
+public class NoboCommunicationException extends Exception {
+
+ private static final long serialVersionUID = -620277949858983367L;
+
+ public NoboCommunicationException(String message) {
+ super(message);
+ }
+
+ public NoboCommunicationException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/NoboDataException.java b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/NoboDataException.java
new file mode 100644
index 000000000..cbdecb8e6
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/NoboDataException.java
@@ -0,0 +1,34 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal.model;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Exception thrown when the data received from the hub has unexpected format.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ */
+@NonNullByDefault
+public class NoboDataException extends Exception {
+
+ private static final long serialVersionUID = -620277949858983367L;
+
+ public NoboDataException(String message) {
+ super(message);
+ }
+
+ public NoboDataException(String message, Throwable parent) {
+ super(message, parent);
+ }
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/OverrideMode.java b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/OverrideMode.java
new file mode 100644
index 000000000..0655131b1
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/OverrideMode.java
@@ -0,0 +1,73 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal.model;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The mode of the {@link OverridePlan}. What the value is overridden to.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+public enum OverrideMode {
+
+ NORMAL(0),
+ COMFORT(1),
+ ECO(2),
+ AWAY(3);
+
+ private final int numValue;
+
+ OverrideMode(int numValue) {
+ this.numValue = numValue;
+ }
+
+ public static OverrideMode getByNumber(int value) throws NoboDataException {
+ switch (value) {
+ case 0:
+ return NORMAL;
+ case 1:
+ return COMFORT;
+ case 2:
+ return ECO;
+ case 3:
+ return AWAY;
+ default:
+ throw new NoboDataException(String.format("Unknown override mode %d", value));
+ }
+ }
+
+ public int getNumValue() {
+ return numValue;
+ }
+
+ public static OverrideMode getByName(String name) throws NoboDataException {
+ if (name.isEmpty()) {
+ throw new NoboDataException("Missing name");
+ }
+
+ if ("Normal".equalsIgnoreCase(name)) {
+ return NORMAL;
+ } else if ("Comfort".equalsIgnoreCase(name)) {
+ return COMFORT;
+ } else if ("Eco".equalsIgnoreCase(name)) {
+ return ECO;
+ } else if ("Away".equalsIgnoreCase(name)) {
+ return AWAY;
+ }
+
+ throw new NoboDataException(String.format("Unknown name of override mode: '%s'", name));
+ }
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/OverridePlan.java b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/OverridePlan.java
new file mode 100644
index 000000000..d56511aa8
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/OverridePlan.java
@@ -0,0 +1,100 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal.model;
+
+import java.time.LocalDateTime;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * An override is when the normal weekly program is not followed because it is specified by pressing a switch or using
+ * an app.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ */
+@NonNullByDefault
+public final class OverridePlan {
+
+ private final int id;
+ private final OverrideMode mode;
+ private final OverrideType type;
+ private final @Nullable LocalDateTime startTime;
+ private final @Nullable LocalDateTime endTime;
+ private final OverrideTarget target;
+ private final int targetId;
+
+ public OverridePlan(int id, OverrideMode mode, OverrideType type, @Nullable LocalDateTime startTime,
+ @Nullable LocalDateTime endTime, OverrideTarget target, int targetId) {
+ this.id = id;
+ this.mode = mode;
+ this.type = type;
+ this.startTime = startTime;
+ this.endTime = endTime;
+ this.target = target;
+ this.targetId = targetId;
+ }
+
+ public static OverridePlan fromH04(String h04) throws NoboDataException {
+ String[] parts = h04.split(" ", 8);
+
+ if (parts.length != 8) {
+ throw new NoboDataException(
+ String.format("Unexpected number of parts from hub on H4 call: %d", parts.length));
+ }
+
+ return new OverridePlan(Integer.parseInt(parts[1]), OverrideMode.getByNumber(Integer.parseInt(parts[2])),
+ OverrideType.getByNumber(Integer.parseInt(parts[3])), ModelHelper.toJavaDate(parts[4]),
+ ModelHelper.toJavaDate(parts[5]), OverrideTarget.getByNumber(Integer.parseInt(parts[6])),
+ Integer.parseInt(parts[7]));
+ }
+
+ public static OverridePlan fromMode(OverrideMode mode, LocalDateTime date) {
+ return new OverridePlan(1, mode, OverrideType.NOW, null, null, OverrideTarget.HUB, -1);
+ }
+
+ public String generateCommandString(final String command) {
+ return String.join(" ", command, Integer.toString(id), Integer.toString(mode.getNumValue()),
+ Integer.toString(type.getNumValue()), ModelHelper.toHubDateMinutes(startTime),
+ ModelHelper.toHubDateMinutes(endTime), Integer.toString(target.getNumValue()),
+ Integer.toString(targetId));
+ }
+
+ public int getId() {
+ return id;
+ }
+
+ public OverrideMode getMode() {
+ return mode;
+ }
+
+ public OverrideType getType() {
+ return type;
+ }
+
+ public @Nullable LocalDateTime startTime() {
+ return startTime;
+ }
+
+ public @Nullable LocalDateTime endTime() {
+ return endTime;
+ }
+
+ public OverrideTarget getTarget() {
+ return target;
+ }
+
+ public int getTargetId() {
+ return targetId;
+ }
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/OverrideRegister.java b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/OverrideRegister.java
new file mode 100644
index 000000000..5a24ce4a7
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/OverrideRegister.java
@@ -0,0 +1,62 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal.model;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import javax.validation.constraints.NotNull;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * Stores a mapping between override ids and overrides that are in place.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+public final class OverrideRegister {
+
+ private final @NotNull Map register = new HashMap<>();
+
+ /**
+ * Stores a new Override in the register. If an override exists with the same id, that value is overwritten.
+ *
+ * @param overridePlan The Override to store.
+ */
+ public void put(OverridePlan overridePlan) {
+ register.put(overridePlan.getId(), overridePlan);
+ }
+
+ /**
+ * Removes an override from the registry.
+ *
+ * @param overrideId The override to remove
+ * @return The override that is removed. Null if the override is not found.
+ */
+ public @Nullable OverridePlan remove(int overrideId) {
+ return register.remove(overrideId);
+ }
+
+ /**
+ * Returns an Override from the registry.
+ *
+ * @param overrideId The id of the override to return.
+ * @return Returns the override, or null if it doesnt exist in the regestry.
+ */
+ public @Nullable OverridePlan get(int overrideId) {
+ return register.get(overrideId);
+ }
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/OverrideTarget.java b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/OverrideTarget.java
new file mode 100644
index 000000000..78137b422
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/OverrideTarget.java
@@ -0,0 +1,52 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal.model;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The target of the {@link OverridePlan}. What it applies to.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+public enum OverrideTarget {
+
+ HUB(0),
+ ZONE(1),
+ COMPONENT(2);
+
+ private final int numValue;
+
+ private OverrideTarget(int numValue) {
+ this.numValue = numValue;
+ }
+
+ public static OverrideTarget getByNumber(int value) throws NoboDataException {
+ switch (value) {
+ case 0:
+ return HUB;
+ case 1:
+ return ZONE;
+ case 2:
+ return COMPONENT;
+ default:
+ throw new NoboDataException(String.format("Unknown override target %d", value));
+ }
+ }
+
+ public int getNumValue() {
+ return numValue;
+ }
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/OverrideType.java b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/OverrideType.java
new file mode 100644
index 000000000..e8e81c0c6
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/OverrideType.java
@@ -0,0 +1,55 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal.model;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The type of the {@link OverridePlan}. How long does it last.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+public enum OverrideType {
+
+ NOW(0),
+ TIMER(1),
+ FROM_TO(2),
+ CONSTANT(3);
+
+ private final int numValue;
+
+ OverrideType(int numValue) {
+ this.numValue = numValue;
+ }
+
+ public static OverrideType getByNumber(int value) throws NoboDataException {
+ switch (value) {
+ case 0:
+ return NOW;
+ case 1:
+ return TIMER;
+ case 2:
+ return FROM_TO;
+ case 3:
+ return CONSTANT;
+ default:
+ throw new NoboDataException(String.format("Unknown override type %d", value));
+ }
+ }
+
+ public int getNumValue() {
+ return numValue;
+ }
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/SerialNumber.java b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/SerialNumber.java
new file mode 100644
index 000000000..65bee469a
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/SerialNumber.java
@@ -0,0 +1,116 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal.model;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.nobohub.internal.NoboHubBindingConstants;
+
+/**
+ * Nobø serial numbers are 12 digits where 3 and 3 digits form 2 bytes as decimal. In total 32 bits.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+public final class SerialNumber {
+
+ private final String serialNumber;
+
+ public SerialNumber(String serialNumber) {
+ this.serialNumber = serialNumber.trim();
+ }
+
+ public boolean isWellFormed() {
+ if (serialNumber.length() != 12) {
+ return false;
+ }
+
+ List parts = new ArrayList<>(4);
+ for (int i = 0; i < 4; i++) {
+ parts.add(serialNumber.substring((i * 3), (i * 3) + 3));
+ }
+
+ if (parts.size() != 4) {
+ return false;
+ }
+
+ for (String part : parts) {
+ try {
+ int num = Integer.parseInt(part);
+ if (num < 0 || num > 255) {
+ return false;
+ }
+ } catch (NumberFormatException nfe) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Returns the type string.
+ */
+ public String getTypeIdentifier() {
+ if (!isWellFormed()) {
+ return "Unknown";
+ }
+
+ return serialNumber.substring(0, 3);
+ }
+
+ /**
+ * Returns the type of this component.
+ */
+ public String getComponentType() {
+ String id = getTypeIdentifier();
+ String type = getTypeForSerialNumber(id);
+ if (null != type) {
+ return type;
+ }
+
+ return "Unknown, please contact maintainer to add a new type for " + serialNumber;
+ }
+
+ private @Nullable String getTypeForSerialNumber(String id) {
+ return NoboHubBindingConstants.SERIALNUMBERS_FOR_TYPES.get(id);
+ }
+
+ @Override
+ public String toString() {
+ return serialNumber;
+ }
+
+ @Override
+ public boolean equals(@Nullable Object obj) {
+ if (this == obj) {
+ return true;
+ }
+
+ if (obj == null || obj.getClass() != this.getClass()) {
+ return false;
+ }
+
+ SerialNumber other = (SerialNumber) obj;
+ return this.serialNumber.equals(other.serialNumber);
+ }
+
+ @Override
+ public int hashCode() {
+ return this.serialNumber.hashCode();
+ }
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/Temperature.java b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/Temperature.java
new file mode 100644
index 000000000..a617a3a7b
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/Temperature.java
@@ -0,0 +1,66 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal.model;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Nobø serial numbers are 12 digits where 3 and 3 digits form 2 bytes as decimal. In total 32 bits.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ */
+@NonNullByDefault
+public final class Temperature {
+
+ private final SerialNumber serialNumber;
+ private final double temperature;
+
+ public Temperature(SerialNumber serialNumber, double temperature) {
+ this.serialNumber = serialNumber;
+ this.temperature = temperature;
+ }
+
+ public static Temperature fromY02(String y02) throws NoboDataException {
+ String parts[] = y02.split(" ", 3);
+ if (parts.length != 3) {
+ throw new NoboDataException(
+ String.format("Unexpected number of parts from hub on Y02 call: %d", parts.length));
+ }
+
+ if (parts[2] == null) {
+ throw new NoboDataException("Missing temperature data");
+ }
+
+ SerialNumber serialNumber = new SerialNumber(parts[1]);
+ double temp = Double.NaN;
+
+ if (!"N/A".equals(parts[2])) {
+ try {
+ temp = Double.parseDouble(parts[2]);
+ } catch (NumberFormatException nfe) {
+ throw new NoboDataException(
+ String.format("Failed to parse temperature %s: %s", parts[2], nfe.getMessage()), nfe);
+ }
+ }
+
+ return new Temperature(serialNumber, temp);
+ }
+
+ public SerialNumber getSerialNumber() {
+ return serialNumber;
+ }
+
+ public double getTemperature() {
+ return temperature;
+ }
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/WeekProfile.java b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/WeekProfile.java
new file mode 100644
index 000000000..abef0f3c8
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/WeekProfile.java
@@ -0,0 +1,117 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal.model;
+
+import java.time.DayOfWeek;
+import java.time.LocalDateTime;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.nobohub.internal.NoboHubBindingConstants;
+
+/**
+ * The normal week profile (used when no {@link OverridePlan}s exist).
+ *
+ * @author Jørgen Austvik - Initial contribution
+ */
+@NonNullByDefault
+public final class WeekProfile {
+
+ private final int id;
+ private final String name;
+ private final String profile;
+
+ public WeekProfile(int id, String name, String profile) {
+ this.id = id;
+ this.name = name;
+ this.profile = profile;
+ }
+
+ public static WeekProfile fromH03(String h03) throws NoboDataException {
+ String[] parts = h03.split(" ", 4);
+
+ if (parts.length != 4) {
+ throw new NoboDataException(
+ String.format("Unexpected number of parts from hub on H3 call: %d", parts.length));
+ }
+
+ return new WeekProfile(Integer.parseInt(parts[1]), ModelHelper.toJavaString(parts[2]),
+ ModelHelper.toJavaString(parts[3]));
+ }
+
+ public int getId() {
+ return id;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public String getProfile() {
+ return profile;
+ }
+
+ /**
+ * Returns the current status on the week profile (unless there is an override).
+ *
+ * @param time The current time
+ * @return The current status (according to the week profile)
+ */
+ public WeekProfileStatus getStatusAt(LocalDateTime time) throws NoboDataException {
+ final DayOfWeek weekDay = time.getDayOfWeek();
+ final int dayNumber = weekDay.getValue();
+ final String timeString = time.format(NoboHubBindingConstants.TIME_FORMAT_MINUTES);
+ String[] parts = profile.split(",");
+
+ int dayCounter = 0;
+ for (int i = 0; i < parts.length; i++) {
+ String current = parts[i];
+ if (current.startsWith("0000")) {
+ dayCounter++;
+ }
+
+ if (current.length() != 5) {
+ throw new NoboDataException("Illegal week profile entry: " + current);
+ }
+
+ if (dayNumber == dayCounter) {
+ String next = "24000";
+ if (i + 1 < parts.length) {
+ if (!parts[i + 1].startsWith("0000")) {
+ next = parts[i + 1];
+ }
+ }
+
+ if (next.length() != 5) {
+ throw new NoboDataException("Illegal week profile entry for next entry: " + next);
+ }
+
+ try {
+ String currentTime = current.substring(0, 4);
+ String nextTime = next.substring(0, 4);
+ if (currentTime.compareTo(timeString) <= 0 && timeString.compareTo(nextTime) < 0) {
+ try {
+ return WeekProfileStatus.getByNumber(Integer.parseInt(String.valueOf(current.charAt(4))));
+ } catch (NumberFormatException nfe) {
+ throw new NoboDataException("Failed parsing week profile entry: " + current, nfe);
+ }
+ }
+ } catch (IndexOutOfBoundsException oobe) {
+ throw new NoboDataException("Illegal time string" + current + ", " + next, oobe);
+ }
+ }
+ }
+
+ throw new NoboDataException(
+ String.format("Failed to calculate %s for day %d in '%s'", timeString, dayNumber, profile));
+ }
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/WeekProfileRegister.java b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/WeekProfileRegister.java
new file mode 100644
index 000000000..b8cc4df70
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/WeekProfileRegister.java
@@ -0,0 +1,75 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal.model;
+
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+
+import javax.validation.constraints.NotNull;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * Stores a mapping between week profile ids and week profiles that exists.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ */
+@NonNullByDefault
+public final class WeekProfileRegister {
+
+ private @NotNull Map register = new HashMap();
+
+ /**
+ * Stores a new week profile in the register. If an week profile exists with the same id, that value is overwritten.
+ *
+ * @param profile The week profile to store.
+ */
+ public void put(WeekProfile profile) {
+ register.put(profile.getId(), profile);
+ }
+
+ /**
+ * Removes a WeekProfile from the registry.
+ *
+ * @param weekProfileId The week profile to remove
+ * @return The week profile that is removed. Null if the week profile is not found.
+ */
+ public @Nullable WeekProfile remove(int weekProfileId) {
+ return register.remove(weekProfileId);
+ }
+
+ /**
+ * Returns a WeekProfile from the registry.
+ *
+ * @param weekProfileId The id of the week profile to return.
+ * @return Returns the week profile, or null if it doesnt exist in the registry.
+ */
+ public @Nullable WeekProfile get(int weekProfileId) {
+ return register.get(weekProfileId);
+ }
+
+ /**
+ * Returns all WeekProfiles from the registry.
+ *
+ * @return Returns the week profile, or empty list if no profiles.
+ */
+ public Collection values() {
+ return register.values();
+ }
+
+ public boolean isEmpty() {
+ return register.isEmpty();
+ }
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/WeekProfileStatus.java b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/WeekProfileStatus.java
new file mode 100644
index 000000000..51b09d2ce
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/WeekProfileStatus.java
@@ -0,0 +1,59 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal.model;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The status of the {@link WeekProfile}. What the value is in the week profile. Status OFF is matched both to value 3
+ * and 4, while the documentation says 3, Hub with Hardware version 11123610_rev._1 and production date 20180305
+ * will send value 4 for OFF.
+ * compatibility.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+public enum WeekProfileStatus {
+
+ ECO(0),
+ COMFORT(1),
+ AWAY(2),
+ OFF(3);
+
+ private final int numValue;
+
+ private WeekProfileStatus(int numValue) {
+ this.numValue = numValue;
+ }
+
+ public static WeekProfileStatus getByNumber(int value) throws NoboDataException {
+ switch (value) {
+ case 0:
+ return ECO;
+ case 1:
+ return COMFORT;
+ case 2:
+ return AWAY;
+ case 3:
+ case 4:
+ return OFF;
+ default:
+ throw new NoboDataException(String.format("Unknown week profile status %d", value));
+ }
+ }
+
+ public int getNumValue() {
+ return numValue;
+ }
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/Zone.java b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/Zone.java
new file mode 100644
index 000000000..312f03ea4
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/Zone.java
@@ -0,0 +1,107 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal.model;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * A Zone contains one or more {@link Component}s.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+public final class Zone {
+
+ private final int id;
+ private final String name;
+ private int activeWeekProfileId;
+ private int comfortTemperature;
+ private int ecoTemperature;
+ private final boolean allowOverrides;
+ private @Nullable Double temperature;
+
+ public Zone(int id, String name, int activeWeekProfileId, int comfortTemperature, int ecoTemperature,
+ boolean allowOverrides) throws NoboDataException {
+ this.id = id;
+ this.name = name;
+ this.activeWeekProfileId = activeWeekProfileId;
+ this.comfortTemperature = comfortTemperature;
+ this.ecoTemperature = ecoTemperature;
+ this.allowOverrides = allowOverrides;
+ }
+
+ public static Zone fromH01(String h01) throws NoboDataException {
+ String parts[] = h01.split(" ", 8);
+
+ if (parts.length != 8) {
+ throw new NoboDataException(
+ String.format("Unexpected number of parts from hub on H1 call: %d", parts.length));
+ }
+
+ return new Zone(Integer.parseInt(parts[1]), ModelHelper.toJavaString(parts[2]), Integer.parseInt(parts[3]),
+ Integer.parseInt(parts[4]), Integer.parseInt(parts[5]), "1".equals(parts[6]));
+ }
+
+ public String generateCommandString(final String command) {
+ return String.join(" ", command, Integer.toString(id), ModelHelper.toHubString(name),
+ Integer.toString(activeWeekProfileId), Integer.toString(comfortTemperature),
+ Integer.toString(ecoTemperature), allowOverrides ? "1" : "0", "-1"); // "Active override id" is
+ // deprecated
+ }
+
+ public int getId() {
+ return id;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public int getActiveWeekProfileId() {
+ return activeWeekProfileId;
+ }
+
+ public int getComfortTemperature() {
+ return comfortTemperature;
+ }
+
+ public int getEcoTemperature() {
+ return ecoTemperature;
+ }
+
+ public boolean getAllowOverrides() {
+ return allowOverrides;
+ }
+
+ public void setTemperature(@Nullable Double temperature) {
+ this.temperature = temperature;
+ }
+
+ public @Nullable Double getTemperature() {
+ return temperature;
+ }
+
+ public void setComfortTemperature(int temp) {
+ comfortTemperature = temp;
+ }
+
+ public void setEcoTemperature(int temp) {
+ ecoTemperature = temp;
+ }
+
+ public void setWeekProfile(int weekProfileId) {
+ activeWeekProfileId = weekProfileId;
+ }
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/ZoneRegister.java b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/ZoneRegister.java
new file mode 100644
index 000000000..d3daa00fa
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/ZoneRegister.java
@@ -0,0 +1,67 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal.model;
+
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+
+import javax.validation.constraints.NotNull;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * Stores a mapping between zone ids and zones that exists.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ * @author Espen Fossen - Initial contribution
+ */
+@NonNullByDefault
+public final class ZoneRegister {
+
+ private final @NotNull Map register = new HashMap();
+
+ /**
+ * Stores a new Zone in the register. If a zone exists with the same id, that value is overwritten.
+ *
+ * @param zone The Zone to store.
+ */
+ public void put(Zone zone) {
+ register.put(zone.getId(), zone);
+ }
+
+ /**
+ * Removes a zone from the registry.
+ *
+ * @param zoneId The zone to remove
+ * @return The zone that is removed. Null if the zone is not found.
+ */
+ public @Nullable Zone remove(int zoneId) {
+ return register.remove(zoneId);
+ }
+
+ /**
+ * Returns a Zone from the registry.
+ *
+ * @param zoneId The id of the zone to return.
+ * @return Returns the zone, or null if it doesnt exist in the regestry.
+ */
+ public @Nullable Zone get(int zoneId) {
+ return register.get(zoneId);
+ }
+
+ public Collection values() {
+ return register.values();
+ }
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/main/resources/OH-INF/binding/binding.xml b/bundles/org.openhab.binding.nobohub/src/main/resources/OH-INF/binding/binding.xml
new file mode 100644
index 000000000..39d0ea55f
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/main/resources/OH-INF/binding/binding.xml
@@ -0,0 +1,9 @@
+
+
+
+ Glen Dimplex Nobø Hub Binding
+ This is the binding for Glen Dimplex Nobø Hub.
+
+
diff --git a/bundles/org.openhab.binding.nobohub/src/main/resources/OH-INF/i18n/nobohub.properties b/bundles/org.openhab.binding.nobohub/src/main/resources/OH-INF/i18n/nobohub.properties
new file mode 100644
index 000000000..0d0174125
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/main/resources/OH-INF/i18n/nobohub.properties
@@ -0,0 +1,57 @@
+# binding
+
+binding.nobohub.name = Glen Dimplex Nobø Hub Binding
+binding.nobohub.description = This is the binding for Glen Dimplex Nobø Hub.
+
+# thing types
+
+thing-type.nobohub.component.label = Component
+thing-type.nobohub.component.description = A component is an oven, a switch or a floor thermostat
+thing-type.nobohub.nobohub.label = Nobø Hub
+thing-type.nobohub.nobohub.description = Nobø Hub Bridge Binding
+thing-type.nobohub.zone.label = Zone
+thing-type.nobohub.zone.description = A zone can contain several Nobø devices
+
+# thing types config
+
+thing-type.config.nobohub.component.serialNumber.label = Serial Number
+thing-type.config.nobohub.component.serialNumber.description = Serial number of the component (12 digits)
+thing-type.config.nobohub.nobohub.hostName.label = Host Name
+thing-type.config.nobohub.nobohub.hostName.description = Host Name/IP address of the Nobø Hub
+thing-type.config.nobohub.nobohub.keepaliveInterval.label = Polling interval
+thing-type.config.nobohub.nobohub.keepaliveInterval.description = Polling interval (seconds). Default: 14.
+thing-type.config.nobohub.nobohub.serialNumber.label = Serial Number
+thing-type.config.nobohub.nobohub.serialNumber.description = Serial number of the Nobø hub (12 numbers, no spaces)
+thing-type.config.nobohub.zone.id.label = Id
+thing-type.config.nobohub.zone.id.description = Id of the Zone
+
+# channel types
+
+channel-type.nobohub.activeOverrideName-channel-type.label = Active Override
+channel-type.nobohub.activeOverrideName-channel-type.description = Mode of active override, using one of the predefined states supported
+channel-type.nobohub.activeOverrideName-channel-type.state.option.NORMAL = Normal
+channel-type.nobohub.activeOverrideName-channel-type.state.option.COMFORT = Comfort
+channel-type.nobohub.activeOverrideName-channel-type.state.option.ECO = Eco
+channel-type.nobohub.activeOverrideName-channel-type.state.option.Away = Away
+channel-type.nobohub.activeWeekProfile-channel-type.label = Active Week Profile Id
+channel-type.nobohub.activeWeekProfile-channel-type.description = Id of the active week profile, set via the Nobø app
+channel-type.nobohub.activeWeekProfileName-channel-type.label = Active Week Profile Name
+channel-type.nobohub.activeWeekProfileName-channel-type.description = Name of the active week profile, set via the Nobø app
+channel-type.nobohub.comfort-temperature-channel-type.label = Comfort Temperature
+channel-type.nobohub.comfort-temperature-channel-type.description = The preferred Comfort temperature level set on the heater or in the binding
+channel-type.nobohub.eco-temperature-channel-type.label = Eco Temperature
+channel-type.nobohub.eco-temperature-channel-type.description = The preferred Eco temperature level set on the heater or in the binding
+channel-type.nobohub.temperature-channel-type.label = Current Temperature
+channel-type.nobohub.temperature-channel-type.description = The current temperature from a device that supports reporting temperatures
+channel-type.nobohub.weekProfiles-channel-type.label = Week Profiles
+channel-type.nobohub.weekProfiles-channel-type.description = Name of the active week profile, set via the Nobø app
+
+# User Messages
+message.missing.serial = Missing serial number in configuration
+message.bridge.status.failed = Failed to get status: {0}
+message.bridge.missing.hostname = Missing host name in configuration
+message.bridge.connection.failed = Failed to connect, check network connectivity and configuration
+message.component.illegal.serial = Illegal serial number: {0}
+message.component.notfound = Could not find Component with serial number {0} for channel {1}
+message.component.missing.id = Id not set for channel {0}
+message.zone.notfound = Could not find Zone with id {0} for channel {1}
diff --git a/bundles/org.openhab.binding.nobohub/src/main/resources/OH-INF/i18n/nobohub_no.properties b/bundles/org.openhab.binding.nobohub/src/main/resources/OH-INF/i18n/nobohub_no.properties
new file mode 100644
index 000000000..0a5546a66
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/main/resources/OH-INF/i18n/nobohub_no.properties
@@ -0,0 +1,57 @@
+# binding
+
+binding.nobohub.name = Glen Dimplex Nobø Hub Binding
+binding.nobohub.description = Dette er en binding for Glen Dimplex Nobø Hub.
+
+# thing types
+
+thing-type.nobohub.component.label = Komponent
+thing-type.nobohub.component.description = En komponent kan være en panelovn, bryter eller gulv termostat
+thing-type.nobohub.nobohub.label = Nobø Hub
+thing-type.nobohub.nobohub.description = Nobø Hub Bru Binding
+thing-type.nobohub.zone.label = Sone
+thing-type.nobohub.zone.description = En sone kan inneholde flere Nobø enheter
+
+# thing types config
+
+thing-type.config.nobohub.nobohub.serialNumber.label = Serialnummer
+thing-type.config.nobohub.nobohub.serialNumber.description = Nobø Hub serialnummer (12 tall)
+thing-type.config.nobohub.nobohub.hostName.label = Tjeneradresse
+thing-type.config.nobohub.nobohub.hostName.description = Tjener eller IP addresse til Nobø Hub
+thing-type.config.nobohub.nobohub.keepaliveInterval.label = Tidsintervall
+thing-type.config.nobohub.nobohub.keepaliveInterval.description = Tidsintervall (sekunder). Standardinnstilling: 14.
+thing-type.config.nobohub.component.serialNumber.label = Serialnummer
+thing-type.config.nobohub.component.serialNumber.description = Serialnummer for komponent (12 tall, uten mellomrom)
+thing-type.config.nobohub.zone.id.label = Id
+thing-type.config.nobohub.zone.id.description = Id for sone
+
+# channel types
+
+channel-type.nobohub.activeOverrideName-channel-type.label = Aktiv Overstyring
+channel-type.nobohub.activeOverrideName-channel-type.description = Modus for aktiv overstyring, bruker en av de predefinerte typene som støttes
+channel-type.nobohub.activeOverrideName-channel-type.state.option.NORMAL = Normal
+channel-type.nobohub.activeOverrideName-channel-type.state.option.COMFORT = Komfort
+channel-type.nobohub.activeOverrideName-channel-type.state.option.ECO = Eco
+channel-type.nobohub.activeOverrideName-channel-type.state.option.Away = Borte
+channel-type.nobohub.activeWeekProfile-channel-type.label = Aktiv Ukeprofil Id
+channel-type.nobohub.activeWeekProfile-channel-type.description = Id på nåværende aktiv ukesprofil
+channel-type.nobohub.activeWeekProfileName-channel-type.label = Aktiv Ukeprofil Navn
+channel-type.nobohub.activeWeekProfileName-channel-type.description = Navn på nåværende aktiv ukesprofil
+channel-type.nobohub.comfort-temperature-channel-type.label = Komfort Temperatur
+channel-type.nobohub.comfort-temperature-channel-type.description = Ønsket Komfort temperaturnivå satt på panel eller i binding
+channel-type.nobohub.eco-temperature-channel-type.label = Eco Temperatur
+channel-type.nobohub.eco-temperature-channel-type.description = Ønsket Eco temperaturnivå satt på panel eller i binding
+channel-type.nobohub.temperature-channel-type.label = Nåværende Temperatur
+channel-type.nobohub.temperature-channel-type.description = Nåværende temperatur fra en enhet som støtter rapportering av temperaturer
+channel-type.nobohub.weekProfiles-channel-type.label = Ukeprofiler
+channel-type.nobohub.weekProfiles-channel-type.description = Tilgjengelige ukesprofiler, satt opp via Nobø app
+
+# User Messages
+message.missing.serial = Mangler serialnummer i konfigurasjon
+message.bridge.status.failed = Kunne ikke hente status: {0}
+message.bridge.missing.hostname = Mangler tjenernavn i konfigurasjon
+message.bridge.connection.failed = Kunne ikke koble til, sjekk nettverksforbindelsen og konfigurasjon
+message.component.illegal.serial = Serialnummer er ukjent eller feil: {0}
+message.component.notfound = Kunne ikke finne Komponent med serialnummer {0} for kanal {1}
+message.component.missing.id = Id er ikke satt for kanal {0}
+message.zone.notfound = Kunne ikke finne Sone med id {0} for kanal {1}
diff --git a/bundles/org.openhab.binding.nobohub/src/main/resources/OH-INF/thing/bridge.xml b/bundles/org.openhab.binding.nobohub/src/main/resources/OH-INF/thing/bridge.xml
new file mode 100644
index 000000000..1d6e4fdca
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/main/resources/OH-INF/thing/bridge.xml
@@ -0,0 +1,38 @@
+
+
+
+
+
+ Nobo Hub Bridge Binding
+
+
+
+
+
+
+
+ Glen Dimplex Nobo
+
+ serialNumber
+
+
+
+
+ Serial number of the Nobo hub (12 numbers, no spaces)
+
+
+
+ Host Name/IP address of the Nobo Hub
+
+
+
+ Polling interval (seconds). Default: 14
+ 14
+
+
+
+
+
diff --git a/bundles/org.openhab.binding.nobohub/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.nobohub/src/main/resources/OH-INF/thing/thing-types.xml
new file mode 100644
index 000000000..56708e048
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/main/resources/OH-INF/thing/thing-types.xml
@@ -0,0 +1,137 @@
+
+
+
+
+
+
+
+
+
+ A zone can contain several Nobo devices
+
+
+
+
+
+
+
+
+
+
+
+ Glen Dimplex Nobo
+
+ name
+
+
+
+
+ Id of the Zone
+
+
+
+
+
+
+
+
+
+
+ A component is an oven, a switch or a floor thermostat
+
+
+
+
+
+
+ Glen Dimplex Nobo
+
+ serialNumber
+
+
+
+
+ Serial number of the component (12 digits)
+
+
+
+
+
+ String
+
+ Name of active override, using one of the predefined states supported
+ Heating
+
+
+
+
+
+
+
+
+
+
+
+ Number:Temperature
+
+ The preferred Eco temperature level set on the heater or in the binding
+ Temperature
+
+ Setpoint
+ Temperature
+
+
+
+
+
+ Number:Temperature
+
+ The preferred Comfort temperature level set on the heater or in the binding
+ Temperature
+
+ Setpoint
+ Temperature
+
+
+
+
+
+ Number:Temperature
+
+ The current temperature from a device that supports reporting temperatures
+ Temperature
+
+ Measurement
+ Temperature
+
+
+
+
+
+ String
+
+ Name of the active week profile, set via the Nobo app
+ Heating
+
+
+
+
+ Number
+
+ Id of the active week profile, set via the Nobo app
+ Heating
+
+
+
+
+ String
+
+ List of active week profiles, set via the Nobo app
+ Heating
+
+
+
+
diff --git a/bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/ComponentRegisterTest.java b/bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/ComponentRegisterTest.java
new file mode 100644
index 000000000..649f630fb
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/ComponentRegisterTest.java
@@ -0,0 +1,79 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal.model;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for ComponentRegister model object.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ */
+@NonNullByDefault
+public class ComponentRegisterTest {
+
+ @Test
+ public void testPutGet() throws NoboDataException {
+ Component c = Component.fromH02("H02 186170024143 0 Kontor 0 1 -1 -1");
+ ComponentRegister sut = new ComponentRegister();
+ sut.put(c);
+ Assertions.assertEquals(c, sut.get(c.getSerialNumber()));
+ }
+
+ @Test
+ public void testPutOverwrite() throws NoboDataException {
+ Component c1 = Component.fromH02("H02 186170024143 0 Kontor 0 1 -1 -1");
+ Component c2 = Component.fromH02("H02 186170024143 0 Bad 0 1 -1 -1");
+ ComponentRegister sut = new ComponentRegister();
+ sut.put(c1);
+ sut.put(c2);
+ Assertions.assertEquals(c2, sut.get(c2.getSerialNumber()));
+ }
+
+ @Test
+ public void testRemove() throws NoboDataException {
+ Component c = Component.fromH02("H02 186170024143 0 Kontor 0 1 -1 -1");
+ ComponentRegister sut = new ComponentRegister();
+ sut.put(c);
+ Component res = sut.remove(c.getSerialNumber());
+ Assertions.assertEquals(c, res);
+ }
+
+ @Test
+ public void testRemoveUnknown() {
+ ComponentRegister sut = new ComponentRegister();
+ Component res = sut.remove(new SerialNumber("123123123123"));
+ Assertions.assertEquals(null, res);
+ }
+
+ @Test
+ public void testGetUnknown() {
+ ComponentRegister sut = new ComponentRegister();
+ Component z = sut.get(new SerialNumber("123123123123"));
+ Assertions.assertEquals(null, z);
+ }
+
+ @Test
+ public void testValues() throws NoboDataException {
+ Component c1 = Component.fromH02("H02 186170024141 0 Kontor 0 1 -1 -1");
+ Component c2 = Component.fromH02("H02 186170024142 0 Soverom 0 1 -1 -1");
+ ComponentRegister sut = new ComponentRegister();
+ sut.put(c1);
+ sut.put(c2);
+ Assertions.assertEquals(2, sut.values().size());
+ Assertions.assertEquals(true, sut.values().contains(c1));
+ Assertions.assertEquals(true, sut.values().contains(c2));
+ }
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/ComponentTest.java b/bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/ComponentTest.java
new file mode 100644
index 000000000..3d1b97b32
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/ComponentTest.java
@@ -0,0 +1,46 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal.model;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for Component model object.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ */
+@NonNullByDefault
+public class ComponentTest {
+ @Test
+ public void testParseH02() throws NoboDataException {
+ Component comp = Component.fromH02("H02 186170024143 0 Kontor 0 1 -1 -1");
+ comp.setTemperature(12.3);
+ assertEquals(new SerialNumber("186170024143"), comp.getSerialNumber());
+ assertEquals("Kontor", comp.getName());
+ assertEquals(1, comp.getZoneId());
+ assertEquals(-1, comp.getTemperatureSensorForZoneId());
+ assertFalse(comp.inReverse());
+ assertEquals(12.3, comp.getTemperature(), 0.1);
+ }
+
+ @Test
+ public void testGenerateU03() throws NoboDataException {
+ Component comp = Component.fromH02("H02 186170024143 0 Kontor 0 1 -1 -1");
+ assertEquals("U02 186170024143 0 Kontor 0 1 -1 -1", comp.generateCommandString("U02"));
+ }
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/HubTest.java b/bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/HubTest.java
new file mode 100644
index 000000000..e48592aed
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/HubTest.java
@@ -0,0 +1,69 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal.model;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.time.Duration;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for Hub model object.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ */
+@NonNullByDefault
+public class HubTest {
+
+ @Test
+ public void testParseH05() throws NoboDataException {
+ Hub hub = Hub.fromH05("H05 102000092118 My Eco Hub 2880 4 114 11123610_rev._1 20190426");
+ assertEquals(new SerialNumber("102000092118"), hub.getSerialNumber());
+ assertEquals("My Eco Hub", hub.getName());
+ assertEquals(Duration.ofDays(2), hub.getDefaultAwayOverrideLength());
+ assertEquals(4, hub.getActiveOverrideId());
+ assertEquals("114", hub.getSoftwareVersion());
+ assertEquals("11123610_rev._1", hub.getHardwareVersion());
+ assertEquals("20190426", hub.getProductionDate());
+ }
+
+ @Test
+ public void testParseV03() throws NoboDataException {
+ Hub hub = Hub.fromH05("V03 102000092118 My Eco Hub 2880 14 114 11123610_rev._1 20190426");
+ assertEquals(new SerialNumber("102000092118"), hub.getSerialNumber());
+ assertEquals("My Eco Hub", hub.getName());
+ assertEquals(Duration.ofDays(2), hub.getDefaultAwayOverrideLength());
+ assertEquals(14, hub.getActiveOverrideId());
+ assertEquals("114", hub.getSoftwareVersion());
+ assertEquals("11123610_rev._1", hub.getHardwareVersion());
+ assertEquals("20190426", hub.getProductionDate());
+ }
+
+ @Test
+ public void testGenerateU03() throws NoboDataException {
+ Hub hub = Hub.fromH05("V03 102000092118 My Eco Hub 2880 14 114 11123610_rev._1 20190426");
+ assertEquals("U03 102000092118 My Eco Hub 2880 14 114 11123610_rev._1 20190426",
+ hub.generateCommandString("U03"));
+ }
+
+ @Test
+ public void testCanChangeOverride() throws NoboDataException {
+ Hub hub = Hub.fromH05("V03 102000092118 My Eco Hub 2880 14 114 11123610_rev._1 20190426");
+ hub.setActiveOverrideId(123);
+ assertEquals("U03 102000092118 My Eco Hub 2880 123 114 11123610_rev._1 20190426",
+ hub.generateCommandString("U03"));
+ }
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/ModelHelperTest.java b/bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/ModelHelperTest.java
new file mode 100644
index 000000000..4a99cf634
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/ModelHelperTest.java
@@ -0,0 +1,85 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal.model;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.time.LocalDateTime;
+import java.time.Month;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit test for ModelHelper class.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ */
+@NonNullByDefault
+public class ModelHelperTest {
+
+ @Test
+ public void testParseJavaStringNoSpace() {
+ assertEquals("NoSpace", ModelHelper.toJavaString("NoSpace"));
+ }
+
+ @Test
+ public void testParseJavaStringNormalSpace() {
+ assertEquals("Contains Space", ModelHelper.toJavaString("Contains Space"));
+ }
+
+ @Test
+ public void testParseJavaStringNoBreakSpace() {
+ assertEquals("Contains NoBreak Space", ModelHelper.toJavaString("Contains" + (char) 160 + "NoBreak Space"));
+ }
+
+ @Test
+ public void testGenerateNoboStringNoSpace() {
+ assertEquals("NoSpace", ModelHelper.toHubString("NoSpace"));
+ }
+
+ @Test
+ public void testGenerateNoboStringNormalSpace() {
+ assertEquals("Contains" + (char) 160 + "NoBreak", ModelHelper.toHubString("Contains" + (char) 160 + "NoBreak"));
+ }
+
+ @Test
+ public void testGenerateNoboStringNoBreakSpace() {
+ assertEquals("Contains" + (char) 160 + "NoBreak" + (char) 160 + "Space",
+ ModelHelper.toHubString("Contains NoBreak Space"));
+ }
+
+ @Test
+ public void testParseNull() throws NoboDataException {
+ assertNull(ModelHelper.toJavaDate("-1"));
+ }
+
+ @Test
+ public void testParseDate() throws NoboDataException {
+ LocalDateTime date = LocalDateTime.of(2020, Month.JANUARY, 22, 19, 30);
+ assertEquals(date, ModelHelper.toJavaDate("202001221930"));
+ }
+
+ @Test()
+ public void testParseIllegalDate() {
+ assertThrows(NoboDataException.class, () -> ModelHelper.toJavaDate("20201322h1930"));
+ }
+
+ @Test
+ public void testGenerateNoboDate() {
+ LocalDateTime date = LocalDateTime.of(2020, Month.JANUARY, 22, 19, 30);
+ assertEquals("202001221930", ModelHelper.toHubDateMinutes(date));
+ }
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/OverridePlanRegisterTest.java b/bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/OverridePlanRegisterTest.java
new file mode 100644
index 000000000..9085fdec2
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/OverridePlanRegisterTest.java
@@ -0,0 +1,69 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal.model;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for OverrideRegister model object.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ */
+@NonNullByDefault
+public class OverridePlanRegisterTest {
+
+ @Test
+ public void testPutGet() throws NoboDataException {
+ OverridePlan o = OverridePlan.fromH04("H04 4 0 0 -1 -1 0 -1");
+ OverrideRegister sut = new OverrideRegister();
+ sut.put(o);
+ assertEquals(o, sut.get(o.getId()));
+ }
+
+ @Test
+ public void testPutOverwrite() throws NoboDataException {
+ OverridePlan o1 = OverridePlan.fromH04("H04 4 0 0 -1 -1 0 -1");
+ OverridePlan o2 = OverridePlan.fromH04("H04 4 3 0 -1 -1 0 -1");
+ OverrideRegister sut = new OverrideRegister();
+ sut.put(o1);
+ sut.put(o2);
+ assertEquals(o2, sut.get(o2.getId()));
+ }
+
+ @Test
+ public void testRemove() throws NoboDataException {
+ OverridePlan o = OverridePlan.fromH04("H04 4 0 0 -1 -1 0 -1");
+ OverrideRegister sut = new OverrideRegister();
+ sut.put(o);
+ OverridePlan res = sut.remove(o.getId());
+ assertEquals(o, res);
+ }
+
+ @Test
+ public void testRemoveUnknown() {
+ OverrideRegister sut = new OverrideRegister();
+ OverridePlan res = sut.remove(666);
+ assertNull(res);
+ }
+
+ @Test
+ public void testGetUnknown() {
+ OverrideRegister sut = new OverrideRegister();
+ OverridePlan o = sut.get(666);
+ assertNull(o);
+ }
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/OverridePlanTest.java b/bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/OverridePlanTest.java
new file mode 100644
index 000000000..5a02eb4ec
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/OverridePlanTest.java
@@ -0,0 +1,90 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal.model;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import java.time.LocalDateTime;
+import java.time.Month;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for Override model object.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ */
+@NonNullByDefault
+public class OverridePlanTest {
+
+ @Test
+ public void testParseH04DefaultOverride() throws NoboDataException {
+ OverridePlan parsed = OverridePlan.fromH04("H04 4 0 0 -1 -1 0 -1");
+ assertEquals(4, parsed.getId());
+ assertEquals(OverrideMode.NORMAL, parsed.getMode());
+ assertEquals(OverrideType.NOW, parsed.getType());
+ assertEquals(OverrideTarget.HUB, parsed.getTarget());
+ assertEquals(-1, parsed.getTargetId());
+ assertNull(parsed.startTime());
+ assertNull(parsed.endTime());
+ }
+
+ @Test
+ public void testParseB03WithStartDate() throws NoboDataException {
+ OverridePlan parsed = OverridePlan.fromH04("B03 9 3 1 202001221930 -1 0 -1");
+ assertEquals(9, parsed.getId());
+ assertEquals(OverrideMode.AWAY, parsed.getMode());
+ assertEquals(OverrideType.TIMER, parsed.getType());
+ assertEquals(OverrideTarget.HUB, parsed.getTarget());
+ assertEquals(-1, parsed.getTargetId());
+ LocalDateTime date = LocalDateTime.of(2020, Month.JANUARY, 22, 19, 30);
+ assertEquals(date, parsed.startTime());
+ assertNull(parsed.endTime());
+ }
+
+ @Test
+ public void testParseS03NoDate() throws NoboDataException {
+ OverridePlan parsed = OverridePlan.fromH04("S03 13 0 0 -1 -1 0 -1");
+ assertEquals(13, parsed.getId());
+ assertEquals(OverrideMode.NORMAL, parsed.getMode());
+ assertEquals(OverrideType.NOW, parsed.getType());
+ assertEquals(OverrideTarget.HUB, parsed.getTarget());
+ assertEquals(-1, parsed.getTargetId());
+ assertNull(parsed.startTime());
+ assertNull(parsed.endTime());
+ }
+
+ @Test
+ public void testAddA03WithStartDate() throws NoboDataException {
+ OverridePlan parsed = OverridePlan.fromH04("B03 9 3 1 202001221930 -1 0 -1");
+ assertEquals("A03 9 3 1 202001221930 -1 0 -1", parsed.generateCommandString("A03"));
+ }
+
+ @Test
+ public void testFromMode() {
+ LocalDateTime date = LocalDateTime.of(2020, Month.FEBRUARY, 21, 21, 42);
+ OverridePlan overridePlan = OverridePlan.fromMode(OverrideMode.AWAY, date);
+ assertEquals("A03 1 3 0 -1 -1 0 -1", overridePlan.generateCommandString("A03"));
+ }
+
+ @Test
+ public void testModeNames() throws NoboDataException {
+ assertEquals(OverrideMode.AWAY, OverrideMode.getByName("Away"));
+ assertEquals(OverrideMode.ECO, OverrideMode.getByName("ECO"));
+ assertEquals(OverrideMode.NORMAL, OverrideMode.getByName("Normal"));
+ assertEquals(OverrideMode.COMFORT, OverrideMode.getByName("COMFORT"));
+ }
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/SerialNumberTest.java b/bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/SerialNumberTest.java
new file mode 100644
index 000000000..cff376a4b
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/SerialNumberTest.java
@@ -0,0 +1,55 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal.model;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for serial number model object.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ */
+@NonNullByDefault
+public class SerialNumberTest {
+
+ @Test
+ public void testIsWellFormed() {
+ assertTrue(new SerialNumber("123123123123").isWellFormed());
+ assertFalse(new SerialNumber("123123123").isWellFormed());
+ assertFalse(new SerialNumber("123 123 123 123").isWellFormed());
+ assertFalse(new SerialNumber("123123123xyz").isWellFormed());
+ assertFalse(new SerialNumber("123123123987").isWellFormed());
+ }
+
+ @Test
+ public void testGetTypeIdentifier() {
+ assertEquals("123", new SerialNumber("123123123123").getTypeIdentifier());
+ assertEquals("Unknown", new SerialNumber("xyz").getTypeIdentifier());
+ }
+
+ @Test
+ public void testGetComponentType() {
+ assertEquals("NTD-4R", new SerialNumber("186170024143").getComponentType());
+ assertEquals("Nobø Switch", new SerialNumber("234001021010").getComponentType());
+ assertEquals("Unknown, please contact maintainer to add a new type for 123123123123",
+ new SerialNumber("123123123123").getComponentType());
+ assertEquals("Unknown, please contact maintainer to add a new type for foobar",
+ new SerialNumber("foobar").getComponentType());
+ }
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/TemperatureTest.java b/bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/TemperatureTest.java
new file mode 100644
index 000000000..6f04b590e
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/TemperatureTest.java
@@ -0,0 +1,41 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal.model;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for temperature model object.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ */
+@NonNullByDefault
+public class TemperatureTest {
+
+ @Test
+ public void testParseY02() throws NoboDataException {
+ Temperature temp = Temperature.fromY02("Y02 123123123123 12.345");
+ assertEquals(new SerialNumber("123123123123"), temp.getSerialNumber());
+ assertEquals(12.34, temp.getTemperature(), 0.1);
+ }
+
+ @Test
+ public void testParseY02NATemp() throws NoboDataException {
+ Temperature temp = Temperature.fromY02("Y02 123123123123 N/A");
+ assertEquals(new SerialNumber("123123123123"), temp.getSerialNumber());
+ assertEquals(Double.NaN, temp.getTemperature(), 0.1);
+ }
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/WeekProfileRegisterTest.java b/bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/WeekProfileRegisterTest.java
new file mode 100644
index 000000000..1ce895f0e
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/WeekProfileRegisterTest.java
@@ -0,0 +1,82 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal.model;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for WeekProfileRegister model object.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ */
+@NonNullByDefault
+public class WeekProfileRegisterTest {
+
+ @Test
+ public void testPutGet() throws NoboDataException {
+ WeekProfile p1 = WeekProfile.fromH03(
+ "H03 1 Default 00000,06001,08000,15001,23000,00000,06001,08000,15001,23000,00000,06001,08000,15001,23000,00000,06001,08000,15001,23000,00000,06001,08000,15001,00000,07001,00000,07001,23000");
+ WeekProfileRegister sut = new WeekProfileRegister();
+ sut.put(p1);
+ assertEquals(p1, sut.get(p1.getId()));
+ }
+
+ @Test
+ public void testPutOverwrite() throws NoboDataException {
+ WeekProfile p1 = WeekProfile.fromH03(
+ "H03 1 Default 00000,06001,08000,15001,23000,00000,06001,08000,15001,23000,00000,06001,08000,15001,23000,00000,06001,08000,15001,23000,00000,06001,08000,15001,00000,07001,00000,07001,23000");
+ WeekProfile p2 = WeekProfile.fromH03(
+ "H03 2 HomeOffice 00000,06001,09000,15001,23000,00000,06001,08000,15001,23000,00000,06001,08000,15001,23000,00000,06001,08000,15001,23000,00000,06001,08000,15001,00000,07001,00000,07001,23000");
+ WeekProfileRegister sut = new WeekProfileRegister();
+ sut.put(p1);
+ sut.put(p2);
+ assertEquals(p2, sut.get(p2.getId()));
+ }
+
+ @Test
+ public void testRemove() throws NoboDataException {
+ WeekProfile p1 = WeekProfile.fromH03(
+ "H03 1 Default 00000,06001,08000,15001,23000,00000,06001,08000,15001,23000,00000,06001,08000,15001,23000,00000,06001,08000,15001,23000,00000,06001,08000,15001,00000,07001,00000,07001,23000");
+ WeekProfileRegister sut = new WeekProfileRegister();
+ sut.put(p1);
+ WeekProfile res = sut.remove(p1.getId());
+ assertEquals(p1, res);
+ }
+
+ @Test
+ public void testRemoveUnknown() {
+ WeekProfileRegister sut = new WeekProfileRegister();
+ WeekProfile res = sut.remove(666);
+ assertEquals(null, res);
+ }
+
+ @Test
+ public void testGetUnknown() {
+ WeekProfileRegister sut = new WeekProfileRegister();
+ WeekProfile o = sut.get(666);
+ assertEquals(null, o);
+ }
+
+ @Test
+ public void testIsEmpty() throws NoboDataException {
+ WeekProfile p1 = WeekProfile.fromH03(
+ "H03 1 Default 00000,06001,08000,15001,23000,00000,06001,08000,15001,23000,00000,06001,08000,15001,23000,00000,06001,08000,15001,23000,00000,06001,08000,15001,00000,07001,00000,07001,23000");
+ WeekProfileRegister sut = new WeekProfileRegister();
+ assertEquals(true, sut.isEmpty());
+ sut.put(p1);
+ assertEquals(false, sut.isEmpty());
+ }
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/WeekProfileTest.java b/bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/WeekProfileTest.java
new file mode 100644
index 000000000..88c9a73c9
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/WeekProfileTest.java
@@ -0,0 +1,95 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal.model;
+
+import java.time.LocalDateTime;
+import java.time.Month;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for WeekProfile model object.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ */
+@NonNullByDefault
+public class WeekProfileTest {
+
+ private static final LocalDateTime MONDAY = LocalDateTime.of(2020, Month.MAY, 11, 0, 0);
+ private static final LocalDateTime WEDNESDAY = LocalDateTime.of(2020, Month.MAY, 13, 0, 0);
+ private static final LocalDateTime SUNDAY = LocalDateTime.of(2020, Month.MAY, 17, 23, 59);
+
+ @Test
+ public void testParseH03() throws NoboDataException {
+ WeekProfile weekProfile = WeekProfile.fromH03(
+ "H03 1 Default 00000,06001,08000,15001,23000,00000,06001,08000,15001,23000,00000,06001,08000,15001,23000,00000,06001,08000,15001,23000,00000,06001,08000,15001,00000,07001,00000,07001,23000");
+ Assertions.assertEquals(1, weekProfile.getId());
+ Assertions.assertEquals("Default", weekProfile.getName());
+ }
+
+ @Test
+ public void testFindFirstStatus() throws NoboDataException {
+ WeekProfile weekProfile = WeekProfile.fromH03(
+ "H03 1 Default 00000,06001,08000,15001,23000,00000,06001,08000,15001,23000,00000,06001,08000,15001,23000,00000,06001,08000,15001,23000,00000,06001,08000,15001,00000,07001,00000,07001,23000");
+ WeekProfileStatus status = weekProfile.getStatusAt(MONDAY);
+ Assertions.assertEquals(WeekProfileStatus.ECO, status);
+ }
+
+ @Test
+ public void testFindLastStatus() throws NoboDataException {
+ WeekProfile weekProfile = WeekProfile.fromH03(
+ "H03 1 Default 00000,06001,08000,15001,23000,00000,06001,08000,15001,23000,00000,06001,08000,15001,23000,00000,06001,08000,15001,23000,00000,06001,08000,15001,00000,07001,00000,07001,23000");
+ WeekProfileStatus status = weekProfile.getStatusAt(SUNDAY);
+ Assertions.assertEquals(WeekProfileStatus.ECO, status);
+ }
+
+ @Test
+ public void testFindEmptyDayStatus() throws NoboDataException {
+ WeekProfile weekProfile = WeekProfile.fromH03("H03 1 Default 00000,00000,00001,00000,00000,00000,00000");
+ WeekProfileStatus status = weekProfile.getStatusAt(WEDNESDAY);
+ Assertions.assertEquals(WeekProfileStatus.COMFORT, status);
+ }
+
+ @Test
+ public void testFindOffDayStatus() throws NoboDataException {
+ WeekProfile weekProfile = WeekProfile.fromH03("H03 2 Off 00004,00003,00004,00004,00004,00004,00003");
+ WeekProfileStatus statusWen = weekProfile.getStatusAt(WEDNESDAY);
+ Assertions.assertEquals(WeekProfileStatus.OFF, statusWen);
+ WeekProfileStatus statusSat = weekProfile.getStatusAt(SUNDAY);
+ Assertions.assertEquals(WeekProfileStatus.OFF, statusSat);
+ }
+
+ @Test
+ public void testFindStartingNowStatus() throws NoboDataException {
+ WeekProfile weekProfile = WeekProfile.fromH03(
+ "H03 1 Default 00000,06001,08000,15001,23000,00000,06001,08000,15001,23000,00000,06001,08000,15001,23000,00000,06001,08000,15001,23000,00000,06001,08000,15001,00000,07001,00000,07001,23000");
+ WeekProfileStatus status = weekProfile.getStatusAt(MONDAY.plusHours(6));
+ Assertions.assertEquals(WeekProfileStatus.COMFORT, status);
+
+ status = weekProfile.getStatusAt(MONDAY.plusHours(6).plusMinutes(1));
+ Assertions.assertEquals(WeekProfileStatus.COMFORT, status);
+
+ status = weekProfile.getStatusAt(MONDAY.plusHours(6).minusMinutes(1));
+ Assertions.assertEquals(WeekProfileStatus.ECO, status);
+ }
+
+ @Test
+ public void testFindNormalStatus() throws NoboDataException {
+ WeekProfile weekProfile = WeekProfile.fromH03(
+ "H03 1 Default 00000,06001,08000,15001,23000,00000,06001,08000,15001,23000,00000,06001,08000,15001,23000,00000,06001,08000,15001,23000,00000,06001,08000,15001,00000,07001,00000,07001,23000");
+ WeekProfileStatus status = weekProfile.getStatusAt(WEDNESDAY.plusHours(7).plusMinutes(13));
+ Assertions.assertEquals(WeekProfileStatus.COMFORT, status);
+ }
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/ZoneRegisterTest.java b/bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/ZoneRegisterTest.java
new file mode 100644
index 000000000..d667ffc7d
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/ZoneRegisterTest.java
@@ -0,0 +1,80 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal.model;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for ZoneRegister model object.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ */
+@NonNullByDefault
+public class ZoneRegisterTest {
+
+ @Test
+ public void testPutGet() throws NoboDataException {
+ Zone z = Zone.fromH01("H01 1 1. etage 20 22 16 1 -1");
+ ZoneRegister sut = new ZoneRegister();
+ sut.put(z);
+ assertEquals(z, sut.get(z.getId()));
+ }
+
+ @Test
+ public void testPutOverwrite() throws NoboDataException {
+ Zone z1 = Zone.fromH01("H01 1 1. etage 20 22 16 1 -1");
+ Zone z2 = Zone.fromH01("H01 1 2. etage 20 22 16 1 -1");
+ ZoneRegister sut = new ZoneRegister();
+ sut.put(z1);
+ sut.put(z2);
+ assertEquals(z2, sut.get(z2.getId()));
+ }
+
+ @Test
+ public void testRemove() throws NoboDataException {
+ Zone z = Zone.fromH01("H01 1 1. etage 20 22 16 1 -1");
+ ZoneRegister sut = new ZoneRegister();
+ sut.put(z);
+ Zone res = sut.remove(z.getId());
+ assertEquals(z, res);
+ }
+
+ @Test
+ public void testRemoveUnknown() {
+ ZoneRegister sut = new ZoneRegister();
+ Zone res = sut.remove(666);
+ assertEquals(null, res);
+ }
+
+ @Test
+ public void testGetUnknown() {
+ ZoneRegister sut = new ZoneRegister();
+ Zone z = sut.get(666);
+ assertEquals(null, z);
+ }
+
+ @Test
+ public void testValues() throws NoboDataException {
+ Zone z1 = Zone.fromH01("H01 1 1. etage 20 22 16 1 -1");
+ Zone z2 = Zone.fromH01("H01 2 2. etage 20 22 16 1 -1");
+ ZoneRegister sut = new ZoneRegister();
+ sut.put(z1);
+ sut.put(z2);
+ assertEquals(2, sut.values().size());
+ assertEquals(true, sut.values().contains(z1));
+ assertEquals(true, sut.values().contains(z2));
+ }
+}
diff --git a/bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/ZoneTest.java b/bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/ZoneTest.java
new file mode 100644
index 000000000..e4775f069
--- /dev/null
+++ b/bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/ZoneTest.java
@@ -0,0 +1,46 @@
+/**
+ * Copyright (c) 2010-2022 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.nobohub.internal.model;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for Zone model object.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ */
+@NonNullByDefault
+public class ZoneTest {
+
+ @Test
+ public void testParseH01Simple() throws NoboDataException {
+ Zone zone = Zone.fromH01("H01 1 1. etage 20 22 16 1 -1");
+ assertEquals(1, zone.getId());
+ assertEquals("1. etage", zone.getName());
+ assertEquals(20, zone.getActiveWeekProfileId());
+ assertTrue(zone.getAllowOverrides());
+ assertEquals(16, zone.getEcoTemperature());
+ assertEquals(22, zone.getComfortTemperature());
+ }
+
+ @Test
+ public void testGenerateCommand() throws NoboDataException {
+ Zone zone = Zone.fromH01("H01 1 1. etage 20 22 16 1 -1");
+ assertEquals("U00 1 1. etage 20 22 16 1 -1", zone.generateCommandString("U00"));
+ }
+}
diff --git a/bundles/pom.xml b/bundles/pom.xml
index 2df26d5a3..61df9d6e0 100644
--- a/bundles/pom.xml
+++ b/bundles/pom.xml
@@ -256,6 +256,7 @@
org.openhab.binding.nibeuplink
org.openhab.binding.nikobus
org.openhab.binding.nikohomecontrol
+ org.openhab.binding.nobohub
org.openhab.binding.novafinedust
org.openhab.binding.ntp
org.openhab.binding.nuki