]> git.basschouten.com Git - openhab-addons.git/commitdiff
[nobohub] Initial contribution (#12937)
authorEspen Fossen <espenaf@junta.no>
Mon, 22 Aug 2022 21:27:24 +0000 (23:27 +0200)
committerGitHub <noreply@github.com>
Mon, 22 Aug 2022 21:27:24 +0000 (23:27 +0200)
* Added NoboHub binding.

Signed-off-by: Espen Fossen <espenaf@junta.no>
59 files changed:
CODEOWNERS
bom/openhab-addons/pom.xml
bundles/org.openhab.binding.nobohub/NOTICE [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/README.md [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/doc/nobohub.jpg [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/pom.xml [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/main/feature/feature.xml [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/ComponentConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/ComponentHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/Helpers.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/NoboHubBindingConstants.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/NoboHubBridgeConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/NoboHubBridgeHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/NoboHubConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/NoboHubHandlerFactory.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/NoboHubTranslationProvider.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/WeekProfileStateDescriptionOptionsProvider.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/ZoneConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/ZoneHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/connection/HubCommunicationThread.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/connection/HubConnection.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/discovery/NoboHubDiscoveryService.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/discovery/NoboThingDiscoveryService.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/Component.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/ComponentRegister.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/Hub.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/ModelHelper.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/NoboCommunicationException.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/NoboDataException.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/OverrideMode.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/OverridePlan.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/OverrideRegister.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/OverrideTarget.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/OverrideType.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/SerialNumber.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/Temperature.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/WeekProfile.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/WeekProfileRegister.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/WeekProfileStatus.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/Zone.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/main/java/org/openhab/binding/nobohub/internal/model/ZoneRegister.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/main/resources/OH-INF/binding/binding.xml [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/main/resources/OH-INF/i18n/nobohub.properties [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/main/resources/OH-INF/i18n/nobohub_no.properties [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/main/resources/OH-INF/thing/bridge.xml [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/main/resources/OH-INF/thing/thing-types.xml [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/ComponentRegisterTest.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/ComponentTest.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/HubTest.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/ModelHelperTest.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/OverridePlanRegisterTest.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/OverridePlanTest.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/SerialNumberTest.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/TemperatureTest.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/WeekProfileRegisterTest.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/WeekProfileTest.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/ZoneRegisterTest.java [new file with mode: 0644]
bundles/org.openhab.binding.nobohub/src/test/java/org/openhab/binding/nobohub/internal/model/ZoneTest.java [new file with mode: 0644]
bundles/pom.xml

index 7d4e10888c3c1ec3826181fe90b6e920e8f7e4d2..316b63416709654af1dcee3d2ab97d6d1ab8b068 100644 (file)
 /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
index 688265456c2ea907afdcb1bfd74b45da277e349f..f7cb83f0ef58873de1a0785cd945efeb12500a23 100644 (file)
       <artifactId>org.openhab.binding.nikohomecontrol</artifactId>
       <version>${project.version}</version>
     </dependency>
+    <dependency>
+      <groupId>org.openhab.addons.bundles</groupId>
+      <artifactId>org.openhab.binding.nobohub</artifactId>
+      <version>${project.version}</version>
+    </dependency>
     <dependency>
       <groupId>org.openhab.addons.bundles</groupId>
       <artifactId>org.openhab.binding.novafinedust</artifactId>
diff --git a/bundles/org.openhab.binding.nobohub/NOTICE b/bundles/org.openhab.binding.nobohub/NOTICE
new file mode 100644 (file)
index 0000000..38d625e
--- /dev/null
@@ -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 (file)
index 0000000..01b671d
--- /dev/null
@@ -0,0 +1,167 @@
+# NoboHub Binding
+
+This binding controls the Glen Dimplex Nobø Hub using the <a href="https://www.glendimplex.se/media/15650/nobo-hub-api-v-1-1-integration-for-advanced-users.pdf">Nobø Hub API v1.1</a>.
+
+![Nobo Hub](doc/nobohub.jpg)
+
+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"                <heating>       {channel="nobohub:nobohub:controller:activeOverrideName"}
+
+// Panel Heater
+Number:Temperature  PanelHeater_CurrentTemperature  "Setpoint [%.1f °C]"                <temperature>   {channel="nobohub:component:controller:184000000000:currentTemperature"}
+
+// Zone
+String              Zone_ActiveWeekProfileName      "Active week profile name [%s]"     <calendar>      {channel="nobohub:zone:controller:1:activeWeekProfileName"}
+Number              Zone_ActiveWeekProfile          "Active week profile [%d]"          <calendar>      {channel="nobohub:zone:controller:1:activeWeekProfile"}
+String              Zone_ActiveStatus               "Active status %s]"                 <heating>       {channel="nobohub:zone:controller:1:calculatedWeekProfileStatus"}
+Number:Temperature  Zone_ComfortTemperature         "Comfort temperature [%.1f °C]"     <temperature>   {channel="nobohub:zone:controller:1:comfortTemperature"}
+Number:Temperature  Zone_EcoTemperatur              "Eco temperature [%.1f °C]"         <temperature>   {channel="nobohub:zone:controller:1:ecoTemperature"}
+Number:Temperature  Zone_CurrentTemperature         "Current temperature [%.1f °C]"     <temperature>   {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 (file)
index 0000000..18915f2
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 (file)
index 0000000..0aefabd
--- /dev/null
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
+  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+
+  <modelVersion>4.0.0</modelVersion>
+
+  <parent>
+    <groupId>org.openhab.addons.bundles</groupId>
+    <artifactId>org.openhab.addons.reactor.bundles</artifactId>
+    <version>3.4.0-SNAPSHOT</version>
+  </parent>
+
+  <artifactId>org.openhab.binding.nobohub</artifactId>
+
+  <name>openHAB Add-ons :: Bundles :: NoboHub Binding</name>
+
+</project>
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 (file)
index 0000000..45f55ca
--- /dev/null
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<features name="org.openhab.binding.nobohub-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
+       <repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
+
+       <feature name="openhab-binding-nobohub" description="NoboHub Binding" version="${project.version}">
+               <feature>openhab-runtime-base</feature>
+               <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.nobohub/${project.version}</bundle>
+       </feature>
+</features>
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 (file)
index 0000000..b262558
--- /dev/null
@@ -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 (file)
index 0000000..cde394b
--- /dev/null
@@ -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<Temperature> currentTemperature = new QuantityType<>(temp, SIUnits.CELSIUS);
+            updateState(CHANNEL_COMPONENT_CURRENT_TEMPERATURE, currentTemperature);
+        }
+
+        Map<String, String> 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 (file)
index 0000000..30ea68b
--- /dev/null
@@ -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 (file)
index 0000000..712598b
--- /dev/null
@@ -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<ThingTypeUID> AUTODISCOVERED_THING_TYPES_UIDS = new HashSet<>(
+            Arrays.asList(THING_TYPE_ZONE, THING_TYPE_COMPONENT));
+
+    public static final Set<ThingTypeUID> 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<String, String> 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::<String, String> unmodifiableMap));
+
+    // Full list of units: https://help.nobo.no/skriver/?chapterid=344&chapterlanguageid=2
+    public static final Map<String, String> 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::<String, String> 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 (file)
index 0000000..43c16e3
--- /dev/null
@@ -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 (file)
index 0000000..309a6bb
--- /dev/null
@@ -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<String, String> 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<WeekProfile> 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 (file)
index 0000000..b5126b9
--- /dev/null
@@ -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 (file)
index 0000000..f5baaa2
--- /dev/null
@@ -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<ThingTypeUID, ServiceRegistration<?>> discoveryServiceRegs = new HashMap<>();
+    public static final Set<ThingTypeUID> 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 (file)
index 0000000..7066e51
--- /dev/null
@@ -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 (file)
index 0000000..f2d7a09
--- /dev/null
@@ -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 (file)
index 0000000..a3a85d1
--- /dev/null
@@ -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 (file)
index 0000000..38eebf6
--- /dev/null
@@ -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<Temperature> comfortTemperature = new QuantityType<>(zone.getComfortTemperature(),
+                SIUnits.CELSIUS);
+        updateState(CHANNEL_ZONE_COMFORT_TEMPERATURE, comfortTemperature);
+        QuantityType<Temperature> ecoTemperature = new QuantityType<>(zone.getEcoTemperature(), SIUnits.CELSIUS);
+        updateState(CHANNEL_ZONE_ECO_TEMPERATURE, ecoTemperature);
+
+        Double temp = zone.getTemperature();
+        if (temp != null && !Double.isNaN(temp)) {
+            QuantityType<Temperature> 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<StateOption> 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<String, String> 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 (file)
index 0000000..a562e78
--- /dev/null
@@ -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 (file)
index 0000000..b0bdcc8
--- /dev/null
@@ -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 (file)
index 0000000..b199963
--- /dev/null
@@ -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<String, Object> 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 (file)
index 0000000..58c2791
--- /dev/null
@@ -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<Zone> zones) {
+        ThingUID bridge = bridgeHandler.getThing().getUID();
+        List<Thing> 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<String, Object> 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<Component> components) {
+        ThingUID bridge = bridgeHandler.getThing().getUID();
+        List<Thing> 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<String, Object> 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 (file)
index 0000000..ea3be88
--- /dev/null
@@ -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 (file)
index 0000000..42b752e
--- /dev/null
@@ -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<SerialNumber, Component> register = new HashMap<SerialNumber, Component>();
+
+    /**
+     * 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<Component> 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 (file)
index 0000000..659982e
--- /dev/null
@@ -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 (file)
index 0000000..3d1f10f
--- /dev/null
@@ -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 (file)
index 0000000..a437652
--- /dev/null
@@ -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 (file)
index 0000000..cbdecb8
--- /dev/null
@@ -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 (file)
index 0000000..0655131
--- /dev/null
@@ -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 (file)
index 0000000..d56511a
--- /dev/null
@@ -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 (file)
index 0000000..5a24ce4
--- /dev/null
@@ -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<Integer, OverridePlan> 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 (file)
index 0000000..78137b4
--- /dev/null
@@ -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 (file)
index 0000000..e8e81c0
--- /dev/null
@@ -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 (file)
index 0000000..65bee46
--- /dev/null
@@ -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<String> 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 (file)
index 0000000..a617a3a
--- /dev/null
@@ -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 (file)
index 0000000..abef0f3
--- /dev/null
@@ -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 (file)
index 0000000..b8cc4df
--- /dev/null
@@ -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<Integer, WeekProfile> register = new HashMap<Integer, WeekProfile>();
+
+    /**
+     * 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<WeekProfile> 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 (file)
index 0000000..51b09d2
--- /dev/null
@@ -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 (file)
index 0000000..312f03e
--- /dev/null
@@ -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 (file)
index 0000000..d3daa00
--- /dev/null
@@ -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<Integer, Zone> register = new HashMap<Integer, Zone>();
+
+    /**
+     * 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<Zone> 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 (file)
index 0000000..39d0ea5
--- /dev/null
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<binding:binding id="nobohub" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:binding="https://openhab.org/schemas/binding/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/binding/v1.0.0 https://openhab.org/schemas/binding-1.0.0.xsd">
+
+       <name>Glen Dimplex Nobø Hub Binding</name>
+       <description>This is the binding for Glen Dimplex Nobø Hub.</description>
+
+</binding:binding>
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 (file)
index 0000000..0d01741
--- /dev/null
@@ -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 (file)
index 0000000..0a5546a
--- /dev/null
@@ -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 (file)
index 0000000..1d6e4fd
--- /dev/null
@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="nobohub"
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+       <bridge-type id="nobohub">
+               <label>Nobo Hub</label>
+               <description>Nobo Hub Bridge Binding</description>
+
+               <channels>
+                       <channel id="activeOverrideName" typeId="activeOverrideName-channel-type"/>
+                       <channel id="weekProfiles" typeId="weekProfiles-channel-type"/>
+               </channels>
+
+               <properties>
+                       <property name="vendor">Glen Dimplex Nobo</property>
+               </properties>
+               <representation-property>serialNumber</representation-property>
+
+               <config-description>
+                       <parameter name="serialNumber" type="text" required="true">
+                               <label>Serial Number</label>
+                               <description>Serial number of the Nobo hub (12 numbers, no spaces)</description>
+                       </parameter>
+                       <parameter name="hostName" type="text" required="true">
+                               <label>Host Name</label>
+                               <description>Host Name/IP address of the Nobo Hub</description>
+                       </parameter>
+                       <parameter name="keepaliveInterval" type="integer" required="false" min="5">
+                               <label>Polling interval</label>
+                               <description>Polling interval (seconds). Default: 14</description>
+                               <default>14</default>
+                       </parameter>
+               </config-description>
+       </bridge-type>
+
+</thing:thing-descriptions>
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 (file)
index 0000000..56708e0
--- /dev/null
@@ -0,0 +1,137 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="nobohub"
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+       <thing-type id="zone">
+               <supported-bridge-type-refs>
+                       <bridge-type-ref id="nobohub"/>
+               </supported-bridge-type-refs>
+
+               <label>Zone</label>
+               <description>A zone can contain several Nobo devices</description>
+
+               <channels>
+                       <channel id="activeWeekProfileName" typeId="activeWeekProfileName-channel-type"/>
+                       <channel id="activeWeekProfile" typeId="activeWeekProfile-channel-type"/>
+                       <channel id="comfortTemperature" typeId="comfort-temperature-channel-type"/>
+                       <channel id="ecoTemperature" typeId="eco-temperature-channel-type"/>
+                       <channel id="currentTemperature" typeId="temperature-channel-type"/>
+                       <channel id="calculatedWeekProfileStatus" typeId="activeOverrideName-channel-type"/>
+               </channels>
+
+               <properties>
+                       <property name="vendor">Glen Dimplex Nobo</property>
+               </properties>
+               <representation-property>name</representation-property>
+
+               <config-description>
+                       <parameter name="id" type="integer" required="true">
+                               <label>Id</label>
+                               <description>Id of the Zone</description>
+                       </parameter>
+               </config-description>
+       </thing-type>
+
+       <thing-type id="component">
+               <supported-bridge-type-refs>
+                       <bridge-type-ref id="nobohub"/>
+               </supported-bridge-type-refs>
+
+               <label>Component</label>
+               <description>A component is an oven, a switch or a floor thermostat</description>
+
+               <channels>
+                       <channel id="currentTemperature" typeId="temperature-channel-type"/>
+               </channels>
+
+               <properties>
+                       <property name="vendor">Glen Dimplex Nobo</property>
+               </properties>
+               <representation-property>serialNumber</representation-property>
+
+               <config-description>
+                       <parameter name="serialNumber" type="text" required="true">
+                               <label>Serial Number</label>
+                               <description>Serial number of the component (12 digits)</description>
+                       </parameter>
+               </config-description>
+       </thing-type>
+
+       <channel-type id="activeOverrideName-channel-type">
+               <item-type>String</item-type>
+               <label>Active Override</label>
+               <description>Name of active override, using one of the predefined states supported</description>
+               <category>Heating</category>
+               <state readOnly="false">
+                       <options>
+                               <option value="NORMAL">Normal</option>
+                               <option value="COMFORT">Comfort</option>
+                               <option value="ECO">Eco</option>
+                               <option value="Away">Away</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="eco-temperature-channel-type">
+               <item-type>Number:Temperature</item-type>
+               <label>Eco Temperature</label>
+               <description>The preferred Eco temperature level set on the heater or in the binding</description>
+               <category>Temperature</category>
+               <tags>
+                       <tag>Setpoint</tag>
+                       <tag>Temperature</tag>
+               </tags>
+               <state min="7" max="30" step="1" pattern="%d °C" readOnly="false"/>
+       </channel-type>
+
+       <channel-type id="comfort-temperature-channel-type">
+               <item-type>Number:Temperature</item-type>
+               <label>Comfort Temperature</label>
+               <description>The preferred Comfort temperature level set on the heater or in the binding</description>
+               <category>Temperature</category>
+               <tags>
+                       <tag>Setpoint</tag>
+                       <tag>Temperature</tag>
+               </tags>
+               <state min="7" max="30" step="1" pattern="%d °C" readOnly="false"/>
+       </channel-type>
+
+       <channel-type id="temperature-channel-type">
+               <item-type>Number:Temperature</item-type>
+               <label>Current Temperature</label>
+               <description>The current temperature from a device that supports reporting temperatures</description>
+               <category>Temperature</category>
+               <tags>
+                       <tag>Measurement</tag>
+                       <tag>Temperature</tag>
+               </tags>
+               <state pattern="%.3f °C" readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="activeWeekProfileName-channel-type">
+               <item-type>String</item-type>
+               <label>Active Week Profile Name</label>
+               <description>Name of the active week profile, set via the Nobo app</description>
+               <category>Heating</category>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="activeWeekProfile-channel-type">
+               <item-type>Number</item-type>
+               <label>Active Week Profile Id</label>
+               <description>Id of the active week profile, set via the Nobo app</description>
+               <category>Heating</category>
+               <state min="0" readOnly="false"/>
+       </channel-type>
+
+       <channel-type id="weekProfiles-channel-type">
+               <item-type>String</item-type>
+               <label>Week Profiles</label>
+               <description>List of active week profiles, set via the Nobo app</description>
+               <category>Heating</category>
+               <state readOnly="true"/>
+       </channel-type>
+
+</thing:thing-descriptions>
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 (file)
index 0000000..649f630
--- /dev/null
@@ -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 (file)
index 0000000..3d1b97b
--- /dev/null
@@ -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 (file)
index 0000000..e48592a
--- /dev/null
@@ -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 (file)
index 0000000..4a99cf6
--- /dev/null
@@ -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 (file)
index 0000000..9085fde
--- /dev/null
@@ -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 (file)
index 0000000..5a02eb4
--- /dev/null
@@ -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 (file)
index 0000000..cff376a
--- /dev/null
@@ -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 (file)
index 0000000..6f04b59
--- /dev/null
@@ -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 (file)
index 0000000..1ce895f
--- /dev/null
@@ -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 (file)
index 0000000..88c9a73
--- /dev/null
@@ -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 (file)
index 0000000..d667ffc
--- /dev/null
@@ -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 (file)
index 0000000..e4775f0
--- /dev/null
@@ -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"));
+    }
+}
index 2df26d5a3881bf5c899a916217f25cc54641c5df..61df9d6e0ce5cdcdb4fb53354e888b2ba5339c38 100644 (file)
     <module>org.openhab.binding.nibeuplink</module>
     <module>org.openhab.binding.nikobus</module>
     <module>org.openhab.binding.nikohomecontrol</module>
+    <module>org.openhab.binding.nobohub</module>
     <module>org.openhab.binding.novafinedust</module>
     <module>org.openhab.binding.ntp</module>
     <module>org.openhab.binding.nuki</module>