diff --git a/CODEOWNERS b/CODEOWNERS
index 403c77efe..8ffad8e51 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -9,6 +9,7 @@
/bundles/org.openhab.automation.jythonscripting/ @openhab/add-ons-maintainers
/bundles/org.openhab.automation.pidcontroller/ @fwolter
/bundles/org.openhab.binding.adorne/ @theiding
+/bundles/org.openhab.binding.airq/ @aurelio1
/bundles/org.openhab.binding.airquality/ @kubawolanin
/bundles/org.openhab.binding.airvisualnode/ @3cky
/bundles/org.openhab.binding.alarmdecoder/ @bobadair @billfor
diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml
index 0037c5b9d..385c85c2d 100644
--- a/bom/openhab-addons/pom.xml
+++ b/bom/openhab-addons/pom.xml
@@ -36,6 +36,11 @@
org.openhab.binding.adorne
${project.version}
+
+ org.openhab.addons.bundles
+ org.openhab.binding.airq
+ ${project.version}
+
org.openhab.addons.bundles
org.openhab.binding.airquality
diff --git a/bundles/org.openhab.binding.airq/NOTICE b/bundles/org.openhab.binding.airq/NOTICE
new file mode 100644
index 000000000..38d625e34
--- /dev/null
+++ b/bundles/org.openhab.binding.airq/NOTICE
@@ -0,0 +1,13 @@
+This content is produced and maintained by the openHAB project.
+
+* Project home: https://www.openhab.org
+
+== Declared Project Licenses
+
+This program and the accompanying materials are made available under the terms
+of the Eclipse Public License 2.0 which is available at
+https://www.eclipse.org/legal/epl-2.0/.
+
+== Source Code
+
+https://github.com/openhab/openhab-addons
diff --git a/bundles/org.openhab.binding.airq/README.md b/bundles/org.openhab.binding.airq/README.md
new file mode 100644
index 000000000..dd581d814
--- /dev/null
+++ b/bundles/org.openhab.binding.airq/README.md
@@ -0,0 +1,223 @@
+# air-Q Binding
+
+The air-Q Binding integrates the air analyzer [air-Q](http://www.air-q.com) device into the openHAB system.
+
+With the binding, it is possible to subscribe to all data delivered by the air-Q device.
+
+![air-Q image](doc/image_air-Q.png)
+
+## Supported Things
+
+Only one Thing is supported: The `airq` device.
+This Binding was tested with an `air-Q Pro` device with 14 sensors. It also works with an `air-Q` device with 11 sensors.
+
+## Discovery
+
+Auto-discovery is not supported.
+
+## Thing Configuration
+
+The air-Q Thing must be configured with (both mandatory):
+
+| Parameter | Description |
+|-----------|------------------------------------|
+| ipAddress | Network address, e.g. 192.168.0.68 |
+| password | Password of the air-Q device |
+
+The Thing provides the following properties:
+
+| Parameter | Description |
+|------------------------|-------------------------------|
+| id | Device ID |
+| hardwareVersion | Hardware version |
+| softwareVersion | Firmware version |
+| sensorList | Available sensors |
+| sensorInfo | Information about the sensors |
+| industry | Industry version |
+
+## Channels
+
+The air-Q Thing offers access to all sensor data of the air-Q, according to its version.
+This includes also the Maximum Error per sensor value.
+For the Maximum Error channels just add `_maxerr` to the channel names.
+
+The rw column is empty if the channel is only readable, w if the channel can be written and rw if it allows both to be read and written.
+
+| channel | type | rw | description |
+|---------------------------|----------------------|--------------------------------------------------------------------------|
+| status | String | | Status of the sensors (usually "OK") |
+| avgFineDustSize | Number:Length | | Average size of Fine Dust [experimental] |
+| fineDustCnt00_3 | Number:Dimensionless | | Fine Dust >0,3 µm |
+| fineDustCnt00_5 | Number:Dimensionless | | Fine Dust >0,5 µm |
+| fineDustCnt01 | Number:Dimensionless | | Fine Dust >1 µm |
+| fineDustCnt02_5 | Number:Dimensionless | | Fine Dust >2,5 µm |
+| fineDustCnt05 | Number:Dimensionless | | Fine Dust >5 µm |
+| fineDustCnt10 | Number:Dimensionless | | Fine Dust >10 µm |
+| co | Number | | CO concentration |
+| co2 | Number:Dimensionless | | CO₂ concentration |
+| dCO2dt | Number | | Change of CO₂ concentration |
+| dHdt | Number | | Change of Humidity |
+| dewpt | Number:Temperature | | Dew Point |
+| doorEvent | Number | | Door Event (experimental, might not work reliably) |
+| health | Number:Dimensionless | | Health Index (0 to 1000, -200 for gas alarm, -800 for fire alarm) |
+| humidityRelative | Number:Dimensionless | | Humidity in percent |
+| humidityAbsolute | Number | | Absolute Humidity |
+| measureTime | Number:Time | | Milliseconds needed for measurement |
+| no2 | Number | | NO₂ concentration |
+| o3 | Number | | Ozone (O₃) concentration |
+| o2 | Number:Dimensionless | | Oxygen (O₂) concentration |
+| performance | Number:Dimensionless | | Performance Index (0 to 1000) |
+| fineDustConc01 | Number | | Fine Dust concentration >1 µm |
+| fineDustConc02_5 | Number | | Fine Dust concentration >2.5 µm |
+| fineDustConc10 | Number | | Fine Dust concentration >10 µm fni |
+| pressure | Number:Pressure | | Pressure |
+| so2 | Number | | SO₂ concentration |
+| sound | Number:Dimensionless | | Noise |
+| temperature | Number:Temperature | | Temperature |
+| timestamp | DateTime | | Timestamp of measurement |
+| tvoc | Number:Dimensionless | | VOC concentration |
+| uptime | Number:Time | | uptime in seconds |
+| wifi | Switch | | WLAN on or off |
+| ssid | String | | WLAN SSID |
+| password | String | w | Device Password |
+| wifiInfo | Switch | rw | Show WLAN status with LED |
+| timeServer | String | rw | Name of Timeserver address |
+| location | Location | rw | Location of air-Q device |
+| nightmodeStartDay | String | rw | Time to start day operation |
+| nightmodeStartNight | String | rw | End of day operation |
+| nightmodeBrightnessDay | Number:Dimensionless | rw | Brightness of LED during the day |
+| nightmodeBrightnessNight | Number:Dimensionless | rw | Brightness of LED at night |
+| nightmodeFanNightOff | Switch | rw | Switch off fan at night |
+| nightmodeWifiNightOff | Switch | rw | Switch off WLAN at night |
+| deviceName | String | | Device Name |
+| roomType | String | rw | Type of room |
+| logLevel | String | w | Logging level |
+| deleteKey | String | w | Settings to be deleted |
+| fireAlarm | Switch | rw | Send Fire Alarm if certain levels are met |
+| wlanConfigGateway | String | rw | Network Gateway |
+| wlanConfigMac | String | rw | MAC Address |
+| wlanConfigSsid | String | rw | WLAN SSID |
+| wlanConfigIPAddress | String | rw | Assigned IP address |
+| wlanConfigNetMask | String | rw | Network mask |
+| wlanConfigBssid | String | rw | Network BSSID |
+| cloudUpload | Switch | rw | Upload to air-Q cloud |
+| averagingRhythm | Number | rw | Rhythm of measurement for historic average |
+| powerFreqSuppression | String | rw | Power Frequency |
+| autoDriftCompensation | Switch | rw | Compensate automatic drift |
+| autoUpdate | Switch | rw | Install Firmware updates automatically |
+| advancedDataProcessing | Switch | rw | Use advanced algorithms eg. for open window or presence of a person |
+| ppm_and_ppb | Switch | rw | Output CO as ppm and NO₂, O₃ and SO₂ as ppb value instead of mg/m3 |
+| gasAlarm | Switch | rw | Send Gas Alarm if certain levels are met |
+| soundPressure | Switch | rw | Sound Pressure Level |
+| alarmForwarding | Switch | rw | Forward gas or fire alarm to other air-Q devices in the household |
+| userCalib | String | | Last sensor calibration |
+| initialCalFinished | Switch | | Initial calibration has finished |
+| averaging | Switch | rw | Do an average |
+| errorBars | Switch | rw | Calculate Maximum Errors |
+| warmupPhase | Switch | rw | Output data as Warmup Phase |
+
+## Example
+
+### air-Q.things
+
+```
+Thing airq:airq:1 "air-Q" [ ipAddress="192.168.0.68", password="myAirQPassword" ]
+```
+
+### air-Q.items
+
+```
+String airQ_status "Status of Sensors" {channel="airq:airq:1:status"}
+Number:Length airQ_avgFineDustSize "Average Size of Fine Dust" {channel="airq:airq:1:avgFineDustSize"}
+Number:Dimensionless airQ_fineDustCnt00_3 "Fine Dust >0,3 µm" {channel="airq:airq:1:fineDustCnt00_3"}
+Number:Dimensionless airQ_fineDustCnt00_5 "Fine Dust >0,5 µm" {channel="airq:airq:1:fineDustCnt00_5"}
+Number:Dimensionless airQ_fineDustCnt01 "Fine Dust >1,0 µm" {channel="airq:airq:1:fineDustCnt01"}
+Number:Dimensionless airQ_fineDustCnt02_5 "Fine Dust >2,5 µm" {channel="airq:airq:1:fineDustCnt02_5"}
+Number:Dimensionless airQ_fineDustCnt05 "Fine Dust >5 µm" {channel="airq:airq:1:fineDustCnt05"}
+Number:Dimensionless airQ_fineDustCnt10 "Fine Dust >10 µm" {channel="airq:airq:1:fineDustCnt10"}
+Number airQ_co "CO Concentration" {channel="airq:airq:1:co"}
+Number:Dimensionless airQ_co2 "CO2 Concentration" {channel="airq:airq:1:co2"}
+Number airQ_dCO2dt "Change of CO2 Concentration" {channel="airq:airq:1:dCO2dt"}
+Number airQ_dHdt "Change of Humidity" {channel="airq:airq:1:dHdt"}
+Number:Temperature airQ_dewpt "Dew Point" {channel="airq:airq:1:dewpt"}
+Number airQ_doorEvent "Door Event (exp.)" {channel="airq:airq:1:doorEvent"}
+Number:Dimensionless airQ_health "Health Index" {channel="airq:airq:1:health"}
+Number:Dimensionless airQ_humidityRelative "Humidity" {channel="airq:airq:1:humidityRelative"}
+Number airQ_humidityAbsolute "Absolute Humidity" {channel="airq:airq:1:humidityAbsolute"}
+Number:Time airQ_measureTime "Time needed for measurement" {channel="airq:airq:1:measureTime"}
+Number airQ_no2 "NO2 concentration" {channel="airq:airq:1:no2"}
+Number airQ_o3 "O3 concentration" {channel="airq:airq:1:o3"}
+Number:Dimensionless airQ_o2 "Oxygen concentration" {channel="airq:airq:1:o2"}
+Number:Dimensionless airQ_performance "Performance Index" {channel="airq:airq:1:performance"}
+Number airQ_fineDustConc01 "Fine Dust Concentration >1µ" {channel="airq:airq:1:fineDustConc01"}
+Number airQ_fineDustConc02_5 "Fine Dust Concentration >2.5µ" {channel="airq:airq:1:fineDustConc02_5"}
+Number airQ_fineDustConc10 "Fine Dust Concentration >10µ" {channel="airq:airq:1:fineDustConc10"}
+Number:Pressure airQ_pressure "Pressure" {channel="airq:airq:1:pressure"}
+Number airQ_so2 "SO2 concentration" {channel="airq:airq:1:so2"}
+Number:Dimensionless airQ_sound "Noise" {channel="airq:airq:1:sound"}
+Number:Temperature airQ_temperature "Temperature" {channel="airq:airq:1:temperature"}
+DateTime airQ_timestamp "TimeStamp [%1$td.%1$tm.%1$tY %1$tH:%1$tM]" {channel="airq:airq:1:timestamp"}
+Number:Dimensionless airQ_voc "VOC concentration" {channel="airq:airq:1:tvoc"}
+Number:Time airQ_uptime "Uptime" {channel="airq:airq:1:uptime"}
+
+Number:Dimensionless airQ_cnt03_maxerr "Maximum error of Fine Dust >0,3 µm" {channel="airq:airq:1:cnt0_3_maxerr"}
+Number:Dimensionless airQ_cnt05_maxerr "Maximum error of Fine Dust >0,5 µm" {channel="airq:airq:1:cnt0_5_maxerr"}
+Number:Dimensionless airQ_cnt1_maxerr "Maximum error of Fine Dust >1,0 µm" {channel="airq:airq:1:cnt1_maxerr"}
+Number:Dimensionless airQ_cnt25_maxerr "Maximum error of Fine Dust >2,5 µm" {channel="airq:airq:1:cnt2_5_maxerr"}
+Number:Dimensionless airQ_cnt5_maxerr "Maximum error of Fine Dust >5 µm" {channel="airq:airq:1:cnt5_maxerr"}
+Number:Dimensionless airQ_cnt10_maxerr "Maximum error of Fine Dust >10 µm" {channel="airq:airq:1:cnt10_maxerr"}
+Number:Dimensionless airQ_co2_maxerr "Maximum error of CO2 Concentration" {channel="airq:airq:1:co2_maxerr"}
+Number:Dimensionless airQ_dewpt_maxerr "Maximum error of Dew Point" {channel="airq:airq:1:dewpt_maxerr"}
+Number:Dimensionless airQ_humidity_maxerr "Maximum error of Humidity" {channel="airq:airq:1:humidity_maxerr"}
+Number:Dimensionless airQ_humidity_abs_maxerr "Maximum error of Absolute Humidity" {channel="airq:airq:1:humidity_abs_maxerr"}
+Number:Dimensionless airQ_no2_maxerr "Maximum error of NO2 concentration" {channel="airq:airq:1:no2_maxerr"}
+Number:Dimensionless airQ_o3_maxerr "Maximum error of O3 concentration" {channel="airq:airq:1:o3_maxerr"}
+Number:Dimensionless airQ_oxygen_maxerr "Maximum error of Oxygen concentration" {channel="airq:airq:1:o2_maxerr"}
+Number:Dimensionless airQ_pm1_maxerr "Maximum error of Fine Dust Concentration >1µ" {channel="airq:airq:1:pm1_maxerr"}
+Number:Dimensionless airQ_pm2_5_maxerr "Maximum error of Fine Dust Concentration >2.5µ" {channel="airq:airq:1:pm2_5_maxerr"}
+Number:Dimensionless airQ_pm10_maxerr "Maximum error of Fine Dust Concentration >10µ" {channel="airq:airq:1:pm10_maxerr"}
+Number:Dimensionless airQ_pressure_maxerr "Maximum error of Pressure" {channel="airq:airq:1:pressure_maxerr"}
+Number:Dimensionless airQ_so2_maxerr "Maximum error of SO2 concentration" {channel="airq:airq:1:so2_maxerr"}
+Number:Dimensionless airQ_sound_maxerr "Maximum error of Noise" {channel="airq:airq:1:sound_maxerr"}
+Number:Dimensionless airQ_temperature_maxerr "Maximum error of Temperature" {channel="airq:airq:1:temperature_maxerr"}
+Number:Dimensionless airQ_voc_maxerr "Maximum error of VOC concentration" {channel="airq:airq:1:tvoc_maxerr"}
+
+Switch airQ_wifi "WLAN on or off" {channel="airq:airq:1:wifi"}
+String airQ_SSID "WLAN SSID" {channel="airq:airq:1:ssid"}
+String airQ_password "Device Password" {channel="airq:airq:1:password"}
+Switch airQ_wifiInfo "Show WLAN status with LED" {channel="airq:airq:1:wifiInfo"}
+String airQ_timeServer "Name of Timeserver address" {channel="airq:airq:1:timeServer"}
+Location airQ_location "Location of air-Q device" {channel="airq:airq:1:location"}
+String airQ_nightMode_startDay "Time to start day operation" {channel="airq:airq:1:nightModeStartDay"}
+String airQ_nightMode_startNight "End of day operation" {channel="airq:airq:1:nightModeStartNight"}
+Number:Dimensionless airQ_nightMode_brightnessDay "Brightness of LED during the day" {channel="airq:airq:1:nightModeBrightnessDay"}
+Number:Dimensionless airQ_nightMode_brightnessNight "Brightness of LED at night" {channel="airq:airq:1:nightModeBrightnessNight"}
+Switch airQ_nightMode_fanNightOff "Switch off fan at night" {channel="airq:airq:1:nightModeFanNightOff"}
+Switch airQ_nightMode_wifiNightOff "Switch off WLAN at night" {channel="airq:airq:1:nightModeWifiNightOff"}
+String airQ_deviceName "Device Name" {channel="airq:airq:1:deviceName"}
+String airQ_roomType "Type of room" {channel="airq:airq:1:roomType"}
+String airQ_logLevel "Logging level" {channel="airq:airq:1:logLevel"}
+String airQ_deleteKey "Settings to be deleted" {channel="airq:airq:1:deleteKey"}
+Switch airQ_fireAlarm "Send Fire Alarm if certain levels are met" {channel="airq:airq:1:fireAlarm"}
+String airQ_WLAN_config_gateway "Network Gateway" {channel="airq:airq:1:wlanConfigGateway"}
+String airQ_WLAN_config_MAC "MAC Address" {channel="airq:airq:1:wlanConfigMac"}
+String airQ_WLAN_config_SSID "WLAN SSID" {channel="airq:airq:1:wlanConfigSsid"}
+String airQ_WLAN_config_IPAddress "Assigned IP address" {channel="airq:airq:1:wlanConfigIPAddress"}
+String airQ_WLAN_config_netMask "Network mask" {channel="airq:airq:1:wlanConfigNetMask"}
+String airQ_WLAN_config_BSSID "Network BSSID" {channel="airq:airq:1:wlanConfigBssid"}
+Switch airQ_cloudUpload "Upload to air-Q cloud" {channel="airq:airq:1:cloudUpload"}
+Number airQ_averagingRhythm "Rhythm of measurement for historic average" {channel="airq:airq:1:averagingRhythm"}
+String airQ_powerFreqSuppression "Power Frequency" {channel="airq:airq:1:powerFreqSuppression"}
+Switch airQ_autoDriftCompensation "Compensate automatic drift" {channel="airq:airq:1:autoDriftCompensation"}
+Switch airQ_autoUpdate "Install Firmware updates automatically" {channel="airq:airq:1:autoUpdate"}
+Switch airQ_advancedDataProcessing "Use advanced algorithms eg. for open window or presence of a person" {channel="airq:airq:1:advancedDataProcessing"}
+Switch airQ_ppm_and_ppb "Output CO as ppm and NO2, O3 and SO2 as ppb value instead of mg/m3" {channel="airq:airq:1:ppm_and_ppb"}
+Switch airQ_gasAlarm "Send Gas Alarm if certain levels are met" {channel="airq:airq:1:gasAlarm"}
+Switch airQ_soundPressure "Sound Pressure Level" {channel="airq:airq:1:soundPressure"}
+Switch airQ_alarmForwarding "Forward gas or fire alarm to other air-Q devices in the household" {channel="airq:airq:1:alarmForwarding"}
+String airQ_userCalib "Last sensor calibration" {channel="airq:airq:1:userCalib"}
+Switch airQ_initialCalFinished "Initial calibration has finished" {channel="airq:airq:1:initialCalFinished"}
+Switch airQ_averaging "Do an average" {channel="airq:airq:1:averaging"}
+Switch airQ_errorBars "Calculate Maximum Errors" {channel="airq:airq:1:errorBars"}
+Switch airQ_warmupPhase "Output Data as Warmup Phase" {channel="airq:airq:1:warmupPhase"}
+```
diff --git a/bundles/org.openhab.binding.airq/doc/image_air-Q.png b/bundles/org.openhab.binding.airq/doc/image_air-Q.png
new file mode 100644
index 000000000..fcc7240e3
Binary files /dev/null and b/bundles/org.openhab.binding.airq/doc/image_air-Q.png differ
diff --git a/bundles/org.openhab.binding.airq/pom.xml b/bundles/org.openhab.binding.airq/pom.xml
new file mode 100644
index 000000000..152fc6cf2
--- /dev/null
+++ b/bundles/org.openhab.binding.airq/pom.xml
@@ -0,0 +1,17 @@
+
+
+
+ 4.0.0
+
+
+ org.openhab.addons.bundles
+ org.openhab.addons.reactor.bundles
+ 3.1.0-SNAPSHOT
+
+
+ org.openhab.binding.airq
+
+ openHAB Add-ons :: Bundles :: air-Q Binding
+
+
diff --git a/bundles/org.openhab.binding.airq/src/main/feature/feature.xml b/bundles/org.openhab.binding.airq/src/main/feature/feature.xml
new file mode 100644
index 000000000..8212fd34a
--- /dev/null
+++ b/bundles/org.openhab.binding.airq/src/main/feature/feature.xml
@@ -0,0 +1,9 @@
+
+
+ mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features
+
+
+ openhab-runtime-base
+ mvn:org.openhab.addons.bundles/org.openhab.binding.airq/${project.version}
+
+
diff --git a/bundles/org.openhab.binding.airq/src/main/java/org/openhab/binding/airq/internal/AirqBindingConstants.java b/bundles/org.openhab.binding.airq/src/main/java/org/openhab/binding/airq/internal/AirqBindingConstants.java
new file mode 100644
index 000000000..5ab53e100
--- /dev/null
+++ b/bundles/org.openhab.binding.airq/src/main/java/org/openhab/binding/airq/internal/AirqBindingConstants.java
@@ -0,0 +1,28 @@
+/**
+ * Copyright (c) 2010-2021 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.airq.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.thing.ThingTypeUID;
+
+/**
+ * The {@link AirqBindingConstants} class defines common constants, which are
+ * used across the whole binding.
+ *
+ * @author Aurelio Caliaro - Initial contribution
+ */
+@NonNullByDefault
+public class AirqBindingConstants {
+ private static final String BINDING_ID = "airq";
+ public static final ThingTypeUID THING_TYPE_AIRQ = new ThingTypeUID(BINDING_ID, "airq");
+}
diff --git a/bundles/org.openhab.binding.airq/src/main/java/org/openhab/binding/airq/internal/AirqConfiguration.java b/bundles/org.openhab.binding.airq/src/main/java/org/openhab/binding/airq/internal/AirqConfiguration.java
new file mode 100644
index 000000000..a5c75a477
--- /dev/null
+++ b/bundles/org.openhab.binding.airq/src/main/java/org/openhab/binding/airq/internal/AirqConfiguration.java
@@ -0,0 +1,27 @@
+/**
+ * Copyright (c) 2010-2021 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.airq.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link AirqConfiguration} class contains fields mapping thing configuration parameters.
+ *
+ * @author Aurelio Caliaro - Initial contribution
+ */
+
+@NonNullByDefault
+public class AirqConfiguration {
+ public String ipAddress = "";
+ public String password = "";
+}
diff --git a/bundles/org.openhab.binding.airq/src/main/java/org/openhab/binding/airq/internal/AirqHandler.java b/bundles/org.openhab.binding.airq/src/main/java/org/openhab/binding/airq/internal/AirqHandler.java
new file mode 100644
index 000000000..bda8cdc71
--- /dev/null
+++ b/bundles/org.openhab.binding.airq/src/main/java/org/openhab/binding/airq/internal/AirqHandler.java
@@ -0,0 +1,789 @@
+/**
+ * Copyright (c) 2010-2021 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.airq.internal;
+
+import java.nio.charset.StandardCharsets;
+import java.security.InvalidAlgorithmParameterException;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.text.SimpleDateFormat;
+import java.time.LocalTime;
+import java.time.format.DateTimeParseException;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Date;
+import java.util.Map.Entry;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import javax.crypto.BadPaddingException;
+import javax.crypto.Cipher;
+import javax.crypto.IllegalBlockSizeException;
+import javax.crypto.NoSuchPaddingException;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.IvParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.client.api.ContentResponse;
+import org.eclipse.jetty.client.api.Request;
+import org.eclipse.jetty.client.util.StringContentProvider;
+import org.eclipse.jetty.http.HttpHeader;
+import org.openhab.core.library.types.DateTimeType;
+import org.openhab.core.library.types.DecimalType;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.PointType;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.library.unit.Units;
+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.UnDefType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+
+/**
+ * The {@link $AirqHandler} is responsible for retrieving all information from the air-Q device
+ * and change properties and channels accordingly.
+ *
+ * @author Aurelio Caliaro - Initial contribution
+ */
+@NonNullByDefault
+public class AirqHandler extends BaseThingHandler {
+
+ private final Logger logger = LoggerFactory.getLogger(AirqHandler.class);
+ private final Gson gson = new Gson();
+ private @Nullable ScheduledFuture> pollingJob;
+ private @Nullable ScheduledFuture> getConfigDataJob;
+ protected static final int POLLING_PERIOD_DATA_MSEC = 15000; // in milliseconds
+ protected static final int POLLING_PERIOD_CONFIG = 1; // in minutes
+ protected final HttpClient httpClient;
+ AirqConfiguration config = new AirqConfiguration();
+
+ final class ResultPair {
+ private final float value;
+ private final float maxdev;
+
+ public float getValue() {
+ return value;
+ }
+
+ public float getMaxdev() {
+ return maxdev;
+ }
+
+ /**
+ * Expects a string consisting of two values as sent by the air-Q device
+ * and returns a corresponding object
+ *
+ * @param input string formed as this: [1234,56,789,012] (including the brackets)
+ * @return ResultPair object with the two values
+ */
+ public ResultPair(String input) {
+ value = Float.parseFloat(input.substring(1, input.indexOf(',')));
+ maxdev = Float.parseFloat(input.substring(input.indexOf(',') + 1, input.length() - 1));
+ }
+ }
+
+ public AirqHandler(Thing thing, HttpClient httpClient) {
+ super(thing);
+ this.httpClient = httpClient;
+ logger.warn("air-Q - airqHandler - constructor: httpClient={}", httpClient);
+ }
+
+ private boolean isTimeFormat(String str) {
+ try {
+ LocalTime.parse(str);
+ } catch (DateTimeParseException e) {
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ public void handleCommand(ChannelUID channelUID, Command command) {
+ if ((command instanceof OnOffType) || (command instanceof StringType)) {
+ JsonObject newobj = new JsonObject();
+ JsonObject subjson = new JsonObject();
+ switch (channelUID.getId()) {
+ case "wifi":
+ // we do not allow to switch off Wifi because otherwise we can't connect to the air-Q device anymore
+ break;
+ case "wifiInfo":
+ newobj.addProperty("WifiInfo", command == OnOffType.ON);
+ changeSettings(newobj);
+ break;
+ case "fireAlarm":
+ newobj.addProperty("FireAlarm", command == OnOffType.ON);
+ changeSettings(newobj);
+ break;
+ case "cloudUpload":
+ newobj.addProperty("cloudUpload", command == OnOffType.ON);
+ changeSettings(newobj);
+ break;
+ case "autoDriftCompensation":
+ newobj.addProperty("AutoDriftCompensation", command == OnOffType.ON);
+ changeSettings(newobj);
+ break;
+ case "autoUpdate":
+ // note that this property is binary but uses 1 and 0 instead of true and false
+ newobj.addProperty("AutoUpdate", command == OnOffType.ON ? 1 : 0);
+ changeSettings(newobj);
+ break;
+ case "advancedDataProcessing":
+ newobj.addProperty("AdvancedDataProcessing", command == OnOffType.ON);
+ changeSettings(newobj);
+ break;
+ case "gasAlarm":
+ newobj.addProperty("GasAlarm", command == OnOffType.ON);
+ changeSettings(newobj);
+ break;
+ case "soundPressure":
+ newobj.addProperty("SoundInfo", command == OnOffType.ON);
+ changeSettings(newobj);
+ break;
+ case "alarmForwarding":
+ newobj.addProperty("AlarmForwarding", command == OnOffType.ON);
+ changeSettings(newobj);
+ break;
+ case "averaging":
+ newobj.addProperty("averaging", command == OnOffType.ON);
+ changeSettings(newobj);
+ break;
+ case "errorBars":
+ newobj.addProperty("ErrorBars", command == OnOffType.ON);
+ changeSettings(newobj);
+ break;
+ case "ppm_and_ppb":
+ newobj.addProperty("ppm&ppb", command == OnOffType.ON);
+ changeSettings(newobj);
+ case "nightmodeFanNightOff":
+ subjson.addProperty("FanNightOff", command == OnOffType.ON);
+ newobj.add("NightMode", subjson);
+ changeSettings(newobj);
+ break;
+ case "nightmodeWifiNightOff":
+ subjson.addProperty("WifiNightOff", command == OnOffType.ON);
+ newobj.add("NightMode", subjson);
+ changeSettings(newobj);
+ break;
+ case "SSID":
+ JsonElement wifidatael = gson.fromJson(command.toString(), JsonElement.class);
+ if (wifidatael != null) {
+ JsonObject wifidataobj = wifidatael.getAsJsonObject();
+ newobj.addProperty("WiFissid", wifidataobj.get("WiFissid").getAsString());
+ newobj.addProperty("WiFipass", wifidataobj.get("WiFipass").getAsString());
+ String bssid = wifidataobj.get("WiFibssid").getAsString();
+ if (!bssid.isEmpty()) {
+ newobj.addProperty("WiFibssid", bssid);
+ }
+ newobj.addProperty("reset", wifidataobj.get("reset").getAsString());
+ changeSettings(newobj);
+ } else {
+ logger.warn("Cannot extract wlan data from this string: {}", wifidatael);
+ }
+ break;
+ case "timeServer":
+ newobj.addProperty(channelUID.getId(), command.toString());
+ changeSettings(newobj);
+ break;
+ case "nightmodeStartDay":
+ if (isTimeFormat(command.toString())) {
+ subjson.addProperty("StartDay", command.toString());
+ newobj.add("NightMode", subjson);
+ changeSettings(newobj);
+ } else {
+ logger.warn(
+ "air-Q - airqHandler - handleCommand(): {} should be set to {} but it isn't a correct time format (eg. 08:00)",
+ channelUID.getId(), command.toString());
+ }
+ break;
+ case "nightmodeStartNight":
+ if (isTimeFormat(command.toString())) {
+ subjson.addProperty("StartNight", command.toString());
+ newobj.add("NightMode", subjson);
+ changeSettings(newobj);
+ } else {
+ logger.warn(
+ "air-Q - airqHandler - handleCommand(): {} should be set to {} but it isn't a correct time format (eg. 08:00)",
+ channelUID.getId(), command.toString());
+ }
+ break;
+ case "location":
+ PointType pt = (PointType) command;
+ subjson.addProperty("lat", pt.getLatitude());
+ subjson.addProperty("long", pt.getLongitude());
+ newobj.add("geopos", subjson);
+ changeSettings(newobj);
+ break;
+ case "nightmodeBrightnessDay":
+ try {
+ subjson.addProperty("BrightnessDay", Float.parseFloat(command.toString()));
+ newobj.add("NightMode", subjson);
+ changeSettings(newobj);
+ } catch (NumberFormatException exc) {
+ logger.warn(
+ "air-Q - airqHandler - handleCommand(): {} only accepts a float value, and {} is not.",
+ channelUID.getId(), command.toString());
+ }
+ break;
+ case "nightmodeBrightnessNight":
+ try {
+ subjson.addProperty("BrightnessNight", Float.parseFloat(command.toString()));
+ newobj.add("NightMode", subjson);
+ changeSettings(newobj);
+ } catch (NumberFormatException exc) {
+ logger.warn(
+ "air-Q - airqHandler - handleCommand(): {} only accepts a float value, and {} is not.",
+ channelUID.getId(), command.toString());
+ }
+ break;
+ case "roomType":
+ newobj.addProperty("RoomType", command.toString());
+ changeSettings(newobj);
+ break;
+ case "logLevel":
+ String ll = command.toString();
+ if (ll.equals("Error") || ll.equals("Warning") || ll.equals("Info")) {
+ newobj.addProperty("logging", ll);
+ changeSettings(newobj);
+ } else {
+ logger.warn(
+ "air-Q - airqHandler - handleCommand(): {} should be set to {} but it isn't a correct setting for the power frequency suppression (only 50Hz or 60Hz)",
+ channelUID.getId(), command.toString());
+ }
+ break;
+ case "averagingRhythm":
+ try {
+ newobj.addProperty("SecondsMeasurementDelay", Integer.parseUnsignedInt(command.toString()));
+ } catch (NumberFormatException exc) {
+ logger.warn(
+ "air-Q - airqHandler - handleCommand(): {} only accepts an integer value, and {} is not.",
+ channelUID.getId(), command.toString());
+ }
+ break;
+ case "powerFreqSuppression":
+ String newFreq = command.toString();
+ if (newFreq.equals("50Hz") || newFreq.equals("60Hz") || newFreq.equals("50Hz+60Hz")) {
+ newobj.addProperty("Rejection", newFreq);
+ changeSettings(newobj);
+ } else {
+ logger.warn(
+ "air-Q - airqHandler - handleCommand(): {} should be set to {} but it isn't a correct setting for the power frequency suppression (only 50Hz or 60Hz)",
+ channelUID.getId(), command.toString());
+ }
+ break;
+ default:
+ logger.warn(
+ "air-Q - airqHandler - handleCommand(): unknown command {} received (channelUID={}, value={})",
+ command, channelUID, command);
+ }
+ }
+ }
+
+ @Override
+ public void initialize() {
+ config = getThing().getConfiguration().as(AirqConfiguration.class);
+ updateStatus(ThingStatus.UNKNOWN);
+ // We don't have to test if ipAddress and password have been set because we have defined them
+ // as being 'required' in thing-types.xml and OpenHAB will only initialize the handler if both are set.
+ String data = getDecryptedContentString("http://" + config.ipAddress + "/data", "GET", null);
+ // we try if the device is reachable and the password is correct. Otherwise a corresponding message is
+ // thrown in Thing manager.
+ if (data == null) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+ "Unable to retrieve get data from air-Q device. Probable cause: invalid password.");
+ } else {
+ updateStatus(ThingStatus.ONLINE);
+ }
+ pollingJob = scheduler.scheduleWithFixedDelay(this::pollData, 0, POLLING_PERIOD_DATA_MSEC,
+ TimeUnit.MILLISECONDS);
+ getConfigDataJob = scheduler.scheduleWithFixedDelay(this::getConfigData, 0, POLLING_PERIOD_CONFIG,
+ TimeUnit.MINUTES);
+ }
+
+ // AES decoding based on this tutorial: https://www.javainterviewpoint.com/aes-256-encryption-and-decryption/
+ public @Nullable String decrypt(byte[] base64text, String password) {
+ String content = "";
+ logger.trace("air-Q - airqHandler - decrypt(): content to decrypt: {}", base64text);
+ byte[] encodedtextwithIV = Base64.getDecoder().decode(base64text);
+ byte[] ciphertext = Arrays.copyOfRange(encodedtextwithIV, 16, encodedtextwithIV.length);
+ byte[] passkey = Arrays.copyOf(password.getBytes(), 32);
+ if (password.length() < 32) {
+ Arrays.fill(passkey, password.length(), 32, (byte) '0');
+ }
+ SecretKey seckey = new SecretKeySpec(passkey, 0, passkey.length, "AES");
+ try {
+ Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
+ SecretKeySpec keySpec = new SecretKeySpec(seckey.getEncoded(), "AES");
+ IvParameterSpec ivSpec = new IvParameterSpec(Arrays.copyOf(encodedtextwithIV, 16));
+ cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);
+ byte[] decryptedText = cipher.doFinal(ciphertext);
+ content = new String(decryptedText, StandardCharsets.UTF_8);
+ logger.trace("air-Q - airqHandler - decrypt(): Text decoded as String: {}", content);
+ } catch (BadPaddingException | NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException
+ | InvalidAlgorithmParameterException | IllegalBlockSizeException exc) {
+ logger.warn("Error while decrypting. Probably the provided password is wrong.");
+ return null;
+ }
+ return content;
+ }
+
+ public String encrypt(byte[] toencode, String password) {
+ String content = "";
+ logger.trace("air-Q - airqHandler - encrypt(): text to encode: {}", new String(toencode));
+ byte[] passkey = Arrays.copyOf(password.getBytes(StandardCharsets.UTF_8), 32);
+ if (password.length() < 32) {
+ Arrays.fill(passkey, password.length(), 32, (byte) '0');
+ }
+ byte[] iv = new byte[16];
+ SecureRandom random = new SecureRandom();
+ random.nextBytes(iv);
+ SecretKey seckey = new SecretKeySpec(passkey, 0, passkey.length, "AES");
+ try {
+ Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
+ SecretKeySpec keySpec = new SecretKeySpec(seckey.getEncoded(), "AES");
+ IvParameterSpec ivSpec = new IvParameterSpec(iv);
+ cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
+ byte[] encryptedText = cipher.doFinal(toencode);
+ byte[] totaltext = new byte[16 + encryptedText.length];
+ System.arraycopy(iv, 0, totaltext, 0, 16);
+ System.arraycopy(encryptedText, 0, totaltext, 16, encryptedText.length);
+ byte[] encodedcontent = Base64.getEncoder().encode(totaltext);
+ logger.trace("air-Q - airqHandler - encrypt(): encrypted text: {}", encodedcontent);
+ content = new String(encodedcontent);
+ } catch (Exception e) {
+ logger.warn("air-Q - airqHandler - encrypt(): Error while encrypting: {}", e.toString());
+ }
+ return content;
+ }
+
+ // gets the data after online/offline management and does the JSON work, or at least the first step.
+ protected @Nullable String getDecryptedContentString(String url, String requestMethod, @Nullable String body) {
+ Result res = null;
+ String jsonAnswer = null;
+ res = getData(url, "GET", null);
+ if (res != null) {
+ String jsontext = res.getBody();
+ logger.trace("air-Q - airqHandler - getDecryptedContentString(): Result from getData() is {} with body={}",
+ res, res.getBody());
+ // Gson code based on https://riptutorial.com/de/gson
+ JsonElement ans = gson.fromJson(jsontext, JsonElement.class);
+ if (ans != null) {
+ JsonObject jsonObj = ans.getAsJsonObject();
+ jsonAnswer = decrypt(jsonObj.get("content").getAsString().getBytes(), config.password);
+ if (jsonAnswer == null) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+ "Decryption not possible, probably wrong password");
+ }
+ } else {
+ logger.warn(
+ "air-Q - airqHandler - getDecryptedContentString(): The air-Q data could not be extracted from this string: {}",
+ ans);
+ }
+ }
+ return jsonAnswer;
+ }
+
+ // calls the networking job and in addition does additional tests for online/offline management
+ protected @Nullable Result getData(String address, String requestMethod, @Nullable String body) {
+ Result res = null;
+ int timeout = 10;
+ logger.debug("air-Q - airqHandler - getData(): connecting to {} with method {} and body {}", address,
+ requestMethod, body);
+ Request request = httpClient.newRequest(address).timeout(timeout, TimeUnit.SECONDS).method(requestMethod);
+ if (body != null) {
+ request = request.content(new StringContentProvider(body)).header(HttpHeader.CONTENT_TYPE,
+ "application/json");
+ }
+ try {
+ ContentResponse response = request.send();
+ res = new Result(response.getContentAsString(), response.getStatus());
+ } catch (InterruptedException | ExecutionException | TimeoutException exc) {
+ logger.warn("air-Q - airqHandler - doNetwork(): Error while accessing air-Q: {}", exc.toString());
+ }
+ if (res == null) {
+ if (getThing().getStatus() != ThingStatus.OFFLINE) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "air-Q device not reachable");
+ } else {
+ logger.warn("air-Q - airqHandler - getData(): retried but still cannot reach the air-Q device.");
+ }
+ } else {
+ if (getThing().getStatus() == ThingStatus.OFFLINE) {
+ updateStatus(ThingStatus.ONLINE);
+ }
+ }
+ return res;
+ }
+
+ public static class Result {
+ private final String body;
+ private final int responseCode;
+
+ public Result(String body, int responseCode) {
+ this.body = body;
+ this.responseCode = responseCode;
+ }
+
+ public String getBody() {
+ return body;
+ }
+
+ public int getResponseCode() {
+ return responseCode;
+ }
+ }
+
+ @Override
+ public void dispose() {
+ if (pollingJob != null) {
+ pollingJob.cancel(true);
+ }
+ if (getConfigDataJob != null) {
+ getConfigDataJob.cancel(true);
+ }
+ }
+
+ public void pollData() {
+ logger.trace("air-Q - airqHandler - run(): starting polled data handler");
+ try {
+ String url = "http://" + config.ipAddress + "/data";
+ String jsonAnswer = getDecryptedContentString(url, "GET", null);
+ if (jsonAnswer != null) {
+ JsonElement decEl = gson.fromJson(jsonAnswer, JsonElement.class);
+ if (decEl != null) {
+ JsonObject decObj = decEl.getAsJsonObject();
+ logger.debug("air-Q - airqHandler - run(): decObj={}, jsonAnswer={}", decObj, jsonAnswer);
+ // 'bat' is a field that is already delivered by air-Q but as
+ // there are no air-Q devices which are powered with batteries
+ // it is obsolete at this moment. We implemented the code anyway
+ // to make it easier to add afterwords, but for the moment it is not applicable.
+ // processType(decObj, "bat", "battery", "pair");
+ processType(decObj, "cnt0_3", "fineDustCnt00_3", "pair");
+ processType(decObj, "cnt0_5", "fineDustCnt00_5", "pair");
+ processType(decObj, "cnt1", "fineDustCnt01", "pair");
+ processType(decObj, "cnt2_5", "fineDustCnt02_5", "pair");
+ processType(decObj, "cnt5", "fineDustCnt05", "pair");
+ processType(decObj, "cnt10", "fineDustCnt10", "pair");
+ processType(decObj, "co", "co", "pair");
+ processType(decObj, "co2", "co2", "pairPPM");
+ processType(decObj, "dewpt", "dewpt", "pair");
+ processType(decObj, "humidity", "humidityRelative", "pair");
+ processType(decObj, "humidity_abs", "humidityAbsolute", "pair");
+ processType(decObj, "no2", "no2", "pair");
+ processType(decObj, "o3", "o3", "pair");
+ processType(decObj, "oxygen", "o2", "pair");
+ processType(decObj, "pm1", "fineDustConc01", "pair");
+ processType(decObj, "pm2_5", "fineDustConc02_5", "pair");
+ processType(decObj, "pm10", "fineDustConc10", "pair");
+ processType(decObj, "pressure", "pressure", "pair");
+ processType(decObj, "so2", "so2", "pair");
+ processType(decObj, "sound", "sound", "pairDB");
+ processType(decObj, "temperature", "temperature", "pair");
+ // We have two places where the Device ID is delivered: with the measurement data and
+ // with the configuration.
+ // We take the info from the configuration and show it as a property, so we don't need
+ // something like processType(decObj, "DeviceID", "DeviceID", "string") at this moment. We leave
+ // this as a reminder in case for some reason it will be needed in future, e.g. when an air-Q
+ // device also sends data from other devices (then with another Device ID)
+ processType(decObj, "Status", "status", "string");
+ processType(decObj, "TypPS", "avgFineDustSize", "number");
+ processType(decObj, "dCO2dt", "dCO2dt", "number");
+ processType(decObj, "dHdt", "dHdt", "number");
+ processType(decObj, "door_event", "doorEvent", "number");
+ processType(decObj, "health", "health", "number");
+ processType(decObj, "measuretime", "measureTime", "number");
+ processType(decObj, "performance", "performance", "number");
+ processType(decObj, "timestamp", "timestamp", "datetime");
+ processType(decObj, "uptime", "uptime", "numberTimePeriod");
+ processType(decObj, "tvoc", "tvoc", "pairPPB");
+ } else {
+ logger.warn("The air-Q data could not be extracted from this string: {}", decEl);
+ }
+ }
+ } catch (Exception e) {
+ logger.warn("air-Q - airqHandler - polldata.run(): Error while retrieving air-Q data: {}", toString());
+ }
+ }
+
+ public void getConfigData() {
+ Result res = null;
+ logger.trace("air-Q - airqHandler - getConfigData(): starting processing data");
+ try {
+ String url = "http://" + config.ipAddress + "/config";
+ res = getData(url, "GET", null);
+ if (res != null) {
+ String jsontext = res.getBody();
+ logger.trace("air-Q - airqHandler - getConfigData(): Result from getBody() is {} with body={}", res,
+ res.getBody());
+ JsonElement ans = gson.fromJson(jsontext, JsonElement.class);
+ if (ans != null) {
+ JsonObject jsonObj = ans.getAsJsonObject();
+ String jsonAnswer = decrypt(jsonObj.get("content").getAsString().getBytes(), config.password);
+ if (jsonAnswer != null) {
+ JsonElement decEl = gson.fromJson(jsonAnswer, JsonElement.class);
+ if (decEl != null) {
+ JsonObject decObj = decEl.getAsJsonObject();
+ logger.debug("air-Q - airqHandler - getConfigData(): decObj={}", decObj);
+ processType(decObj, "Wifi", "wifi", "boolean");
+ processType(decObj, "WLANssid", "ssid", "arr");
+ processType(decObj, "pass", "password", "string");
+ processType(decObj, "WifiInfo", "wifiInfo", "boolean");
+ processType(decObj, "TimeServer", "timeServer", "string");
+ processType(decObj, "geopos", "location", "coord");
+ processType(decObj, "NightMode", "", "nightmode");
+ processType(decObj, "devicename", "deviceName", "string");
+ processType(decObj, "RoomType", "roomType", "string");
+ processType(decObj, "logging", "logLevel", "string");
+ processType(decObj, "DeleteKey", "deleteKey", "string");
+ processType(decObj, "FireAlarm", "fireAlarm", "boolean");
+ processType(decObj, "air-Q-Hardware-Version", "hardwareVersion", "property");
+ processType(decObj, "WLAN config", "", "wlan");
+ processType(decObj, "cloudUpload", "cloudUpload", "boolean");
+ processType(decObj, "SecondsMeasurementDelay", "averagingRhythm", "number");
+ processType(decObj, "Rejection", "powerFreqSuppression", "string");
+ processType(decObj, "air-Q-Software-Version", "softwareVersion", "property");
+ processType(decObj, "sensors", "sensorList", "proparr");
+ processType(decObj, "AutoDriftCompensation", "autoDriftCompensation", "boolean");
+ processType(decObj, "AutoUpdate", "autoUpdate", "boolean");
+ processType(decObj, "AdvancedDataProcessing", "advancedDataProcessing", "boolean");
+ processType(decObj, "Industry", "Industry", "property");
+ processType(decObj, "ppm&ppb", "ppm_and_ppb", "boolean");
+ processType(decObj, "GasAlarm", "gasAlarm", "boolean");
+ processType(decObj, "id", "id", "property");
+ processType(decObj, "SoundInfo", "soundPressure", "boolean");
+ processType(decObj, "AlarmForwarding", "alarmForwarding", "boolean");
+ processType(decObj, "usercalib", "userCalib", "calib");
+ processType(decObj, "InitialCalFinished", "initialCalFinished", "boolean");
+ processType(decObj, "Averaging", "averaging", "boolean");
+ processType(decObj, "SensorInfo", "sensorInfo", "property");
+ processType(decObj, "ErrorBars", "errorBars", "boolean");
+ processType(decObj, "warmup-phase", "warmupPhase", "boolean");
+ } else {
+ logger.warn(
+ "air-Q - airqHandler - getConfigData(): The air-Q data could not be extracted from this string: {}",
+ decEl);
+ }
+ }
+ } else {
+ logger.warn(
+ "air-Q - airqHandler - getConfigData(): The air-Q data could not be extracted from this string: {}",
+ ans);
+ }
+ }
+ } catch (Exception e) {
+ logger.warn("air-Q - airqHandler - getConfigData(): Error in processConfigData(): {}", e.toString());
+ }
+ }
+
+ private void processType(JsonObject dec, String airqName, String channelName, String type) {
+ logger.trace("air-Q - airqHandler - processType(): airqName={}, channelName={}, type={}", airqName, channelName,
+ type);
+ if (dec.get(airqName) == null) {
+ logger.trace("air-Q - airqHandler - processType(): get({}) is null", airqName);
+ updateState(channelName, UnDefType.UNDEF);
+ if (type.contentEquals("pair")) {
+ updateState(channelName + "_maxerr", UnDefType.UNDEF);
+ }
+ } else {
+ switch (type) {
+ case "boolean":
+ String itemval = dec.get(airqName).toString();
+ if (itemval.contentEquals("true") || itemval.contentEquals("1")) {
+ updateState(channelName, OnOffType.ON);
+ } else if (itemval.contentEquals("false") || itemval.contentEquals("0")) {
+ updateState(channelName, OnOffType.OFF);
+ }
+ break;
+ case "string":
+ case "time":
+ String strstr = dec.get(airqName).toString();
+ updateState(channelName, new StringType(strstr.substring(1, strstr.length() - 1)));
+ break;
+ case "number":
+ updateState(channelName, new DecimalType(dec.get(airqName).toString()));
+ break;
+ case "numberTimePeriod":
+ updateState(channelName, new QuantityType<>(dec.get(airqName).getAsBigInteger(), Units.SECOND));
+ break;
+ case "pair":
+ ResultPair pair = new ResultPair(dec.get(airqName).toString());
+ updateState(channelName, new DecimalType(pair.getValue()));
+ updateState(channelName + "_maxerr", new DecimalType(pair.getMaxdev()));
+ break;
+ case "pairPPM":
+ ResultPair pairPPM = new ResultPair(dec.get(airqName).toString());
+ updateState(channelName, new QuantityType<>(pairPPM.getValue(), Units.PARTS_PER_MILLION));
+ updateState(channelName + "_maxerr", new DecimalType(pairPPM.getMaxdev()));
+ break;
+ case "pairPPB":
+ ResultPair pairPPB = new ResultPair(dec.get(airqName).toString());
+ updateState(channelName, new QuantityType<>(pairPPB.getValue(), Units.PARTS_PER_BILLION));
+ updateState(channelName + "_maxerr", new DecimalType(pairPPB.getMaxdev()));
+ break;
+ case "pairDB":
+ ResultPair pairDB = new ResultPair(dec.get(airqName).toString());
+ logger.trace("air-Q - airqHandler - processType(): db transmitted as {} with unit {}",
+ pairDB.getValue(), Units.DECIBEL);
+ updateState(channelName, new QuantityType<>(pairDB.getValue(), Units.DECIBEL));
+ updateState(channelName + "_maxerr", new DecimalType(pairDB.getMaxdev()));
+ break;
+ case "datetime":
+ Long timest = Long.valueOf(dec.get(airqName).toString());
+ SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ");
+ String timestampString = sdf.format(new Date(timest));
+ updateState(channelName, DateTimeType.valueOf(timestampString));
+ break;
+ case "coord":
+ JsonElement ansCoord = gson.fromJson(dec.get(airqName).toString(), JsonElement.class);
+ if (ansCoord != null) {
+ JsonObject jsonCoord = ansCoord.getAsJsonObject();
+ Float latitude = jsonCoord.get("lat").getAsFloat();
+ Float longitude = jsonCoord.get("long").getAsFloat();
+ updateState(channelName, new PointType(new DecimalType(latitude), new DecimalType(longitude)));
+ } else {
+ logger.warn(
+ "air-Q - airqHandler - processType(): Cannot extract coordinates from this data: {}",
+ dec.get(airqName).toString());
+ }
+ break;
+ case "nightmode":
+ JsonElement daynightdata = gson.fromJson(dec.get(airqName).toString(), JsonElement.class);
+ if (daynightdata != null) {
+ JsonObject jsonDaynightdata = daynightdata.getAsJsonObject();
+ processType(jsonDaynightdata, "StartDay", "nightModeStartDay", "string");
+ processType(jsonDaynightdata, "StartNight", "nightModeStartNight", "string");
+ processType(jsonDaynightdata, "BrightnessDay", "nightModeBrightnessDay", "number");
+ processType(jsonDaynightdata, "BrightnessNight", "nightModeBrightnessNight", "number");
+ processType(jsonDaynightdata, "FanNightOff", "nightModeFanNightOff", "boolean");
+ processType(jsonDaynightdata, "WifiNightOff", "nightModeWifiNightOff", "boolean");
+ } else {
+ logger.warn("air-Q - airqHandler - processType(): Cannot extract day/night data: {}",
+ dec.get(airqName).toString());
+ }
+ break;
+ case "wlan":
+ JsonElement wlandata = gson.fromJson(dec.get(airqName).toString(), JsonElement.class);
+ if (wlandata != null) {
+ JsonObject jsonWlandata = wlandata.getAsJsonObject();
+ processType(jsonWlandata, "Gateway", "wlanConfigGateway", "string");
+ processType(jsonWlandata, "MAC", "wlanConfigMac", "string");
+ processType(jsonWlandata, "SSID", "wlanConfigSsid", "string");
+ processType(jsonWlandata, "IP address", "wlanConfigIPAddress", "string");
+ processType(jsonWlandata, "Net Mask", "wlanConfigNetMask", "string");
+ processType(jsonWlandata, "BSSID", "wlanConfigBssid", "string");
+ } else {
+ logger.warn(
+ "air-Q - airqHandler - processType(): Cannot extract WLAN data from this string: {}",
+ dec.get(airqName).toString());
+ }
+ break;
+ case "arr":
+ JsonElement jsonarr = gson.fromJson(dec.get(airqName).toString(), JsonElement.class);
+ if ((jsonarr != null) && (jsonarr.isJsonArray())) {
+ JsonArray arr = jsonarr.getAsJsonArray();
+ StringBuilder str = new StringBuilder();
+ for (JsonElement el : arr) {
+ str.append(el.getAsString() + ", ");
+ }
+ updateState(channelName, new StringType(str.substring(0, str.length() - 2)));
+ } else {
+ logger.warn("air-Q - airqHandler - processType(): cannot handle this as an array: {}", jsonarr);
+ }
+ break;
+ case "calib":
+ JsonElement lastcalib = gson.fromJson(dec.get(airqName).toString(), JsonElement.class);
+ if (lastcalib != null) {
+ JsonObject calibobj = lastcalib.getAsJsonObject();
+ String str = new String();
+ Long timecalib;
+ SimpleDateFormat sdfcalib = new SimpleDateFormat("dd.MM.yyyy' 'HH:mm:ss");
+ for (Entry entry : calibobj.entrySet()) {
+ String attributeName = entry.getKey();
+ JsonObject attributeValue = (JsonObject) entry.getValue();
+ timecalib = Long.valueOf(attributeValue.get("timestamp").toString());
+ String timecalibString = sdfcalib.format(new Date(timecalib * 1000));
+ str = str + attributeName + ": offset=" + attributeValue.get("offset").getAsString() + " ["
+ + timecalibString + "]";
+ }
+ updateState(channelName, new StringType(str.substring(0, str.length() - 1)));
+ } else {
+ logger.warn(
+ "air-Q - airqHandler - processType(): Cannot extract calibration data from this string: {}",
+ dec.get(airqName).toString());
+ }
+ break;
+ case "property":
+ String propstr = dec.get(airqName).toString();
+ getThing().setProperty(channelName, propstr);
+ break;
+ case "proparr":
+ JsonElement proparr = gson.fromJson(dec.get(airqName).toString(), JsonElement.class);
+ if ((proparr != null) && proparr.isJsonArray()) {
+ JsonArray arr = proparr.getAsJsonArray();
+ String arrstr = new String();
+ for (JsonElement el : arr) {
+ arrstr = arrstr + el.getAsString() + ", ";
+ }
+ logger.trace("air-Q - airqHandler - processType(): property array {} set to {}", channelName,
+ arrstr.substring(0, arrstr.length() - 2));
+ getThing().setProperty(channelName, arrstr.substring(0, arrstr.length() - 2));
+ } else {
+ logger.warn("air-Q - airqHandler - processType(): cannot handle this as an array: {}", proparr);
+ }
+ break;
+ default:
+ logger.warn(
+ "air-Q - airqHandler - processType(): a setting of type {} should be changed but I don't know this type.",
+ type);
+ break;
+ }
+ }
+ }
+
+ private void changeSettings(JsonObject jsonchange) {
+ String jsoncmd = jsonchange.toString();
+ logger.trace("air-Q - airqHandler - changeSettings(): called with jsoncmd={}", jsoncmd);
+ Result res = null;
+ String url = "http://" + config.ipAddress + "/config";
+ String jsonbody = encrypt(jsoncmd.getBytes(StandardCharsets.UTF_8), config.password);
+ String fullbody = "request=" + jsonbody;
+ logger.trace("air-Q - airqHandler - changeSettings(): doing call to url={}, method=POST, body={}", url,
+ fullbody);
+ res = getData(url, "POST", fullbody);
+ if (res != null) {
+ JsonElement ans = gson.fromJson(res.getBody(), JsonElement.class);
+ if (ans != null) {
+ JsonObject jsonObj = ans.getAsJsonObject();
+ String jsonAnswer = decrypt(jsonObj.get("content").getAsString().getBytes(), config.password);
+ logger.trace("air-Q - airqHandler - changeSettings(): call returned {}", jsonAnswer);
+ } else {
+ logger.warn("The air-Q data could not be extracted from this string: {}", ans);
+ }
+ }
+ }
+};
diff --git a/bundles/org.openhab.binding.airq/src/main/java/org/openhab/binding/airq/internal/AirqHandlerFactory.java b/bundles/org.openhab.binding.airq/src/main/java/org/openhab/binding/airq/internal/AirqHandlerFactory.java
new file mode 100644
index 000000000..7c8bc2a3e
--- /dev/null
+++ b/bundles/org.openhab.binding.airq/src/main/java/org/openhab/binding/airq/internal/AirqHandlerFactory.java
@@ -0,0 +1,62 @@
+/**
+ * Copyright (c) 2010-2021 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.airq.internal;
+
+import static org.openhab.binding.airq.internal.AirqBindingConstants.THING_TYPE_AIRQ;
+
+import java.util.Collections;
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.io.net.http.HttpClientFactory;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.binding.BaseThingHandlerFactory;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.openhab.core.thing.binding.ThingHandlerFactory;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+
+/**
+ * The {@link AirqHandlerFactory} is responsible for creating the air-Q thing and its handlers.
+ *
+ * @author Aurelio Caliaro - Initial contribution
+ */
+@NonNullByDefault
+@Component(configurationPid = "binding.airq", service = ThingHandlerFactory.class)
+public class AirqHandlerFactory extends BaseThingHandlerFactory {
+
+ private static final Set SUPPORTED_THING_TYPES_UIDS = Collections.singleton(THING_TYPE_AIRQ);
+ private final HttpClientFactory httpClientFactory;
+
+ @Activate
+ public AirqHandlerFactory(@Reference HttpClientFactory httpClientFactory) {
+ this.httpClientFactory = httpClientFactory;
+ }
+
+ @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_AIRQ.equals(thingTypeUID)) {
+ return new AirqHandler(thing, httpClientFactory.getCommonHttpClient());
+ }
+ return null;
+ }
+}
diff --git a/bundles/org.openhab.binding.airq/src/main/resources/OH-INF/binding/binding.xml b/bundles/org.openhab.binding.airq/src/main/resources/OH-INF/binding/binding.xml
new file mode 100644
index 000000000..f490e07f4
--- /dev/null
+++ b/bundles/org.openhab.binding.airq/src/main/resources/OH-INF/binding/binding.xml
@@ -0,0 +1,11 @@
+
+
+
+ air-Q Binding
+ This is the binding for air-Q devices. The air-Q device contains several sensors measuring gases in the
+ air and other ambiance parameters like noise or temperature. With this binding you can integrate those values into
+ your openHAB system and change parametrs of the air-Q device.
+
+
diff --git a/bundles/org.openhab.binding.airq/src/main/resources/OH-INF/i18n/airq_de_DE.properties b/bundles/org.openhab.binding.airq/src/main/resources/OH-INF/i18n/airq_de_DE.properties
new file mode 100644
index 000000000..3c274e2c9
--- /dev/null
+++ b/bundles/org.openhab.binding.airq/src/main/resources/OH-INF/i18n/airq_de_DE.properties
@@ -0,0 +1,125 @@
+# binding
+binding.airq.name = air-Q
+binding.airq.description = Binding für air-Q-Gerät
+
+# thing types
+thing-type.airq.airq.label = air-Q
+thing-type.airq.airq.description = Thing für air-Q-Gerät
+
+# thing type config description
+config.airq.airq.ipAddress.label = Netzwerk-Adresse
+config.airq.sample.config1.description = Netzwerk-Adresse, unter der das air-Q erreichbar ist
+
+# channel types
+channel-type.airq.devid.label = Gerätenummer aus Datenbezug
+channel-type.airq.devid.description = Interne Nummer des air-Q
+channel-type.airq.status.label = Sensorenstatus
+channel-type.airq.status.description = Status der internen Sensoren
+channel-type.airq.typps.label = Durchschn. Staubgrösse (Experimentell)
+channel-type.airq.typps.description = Durchschnittliche Grösse des Feinstaubs
+channel-type.airq.bat.label = Batteriestatus
+channel-type.airq.bat.description = Stand der Batterie, sofern vorhanden
+channel-type.airq.cnt0_3.label = Feinstaub >0,3 μm
+channel-type.airq.cnt0_3.description = Feinstaubpartikel grösser 0,3 μm
+channel-type.airq.cnt0_5.label = Feinstaub >0,5 μm
+channel-type.airq.cnt0_5.description = Feinstaubpartikel grösser 0,5 μm
+channel-type.airq.cnt1.label = Feinstaub >1,0 μm
+channel-type.airq.cnt1.description = Feinstaubpartikel grösser 1,0 μm
+channel-type.airq.cnt2_5.label = Feinstaub >2,5 μm
+channel-type.airq.cnt2_5.description = Feinstaubpartikel grösser 2,5 μm
+channel-type.airq.cnt5.label = Feinstaub >5 μm
+channel-type.airq.cnt5.description = Feinstaubpartikel grösser 5 μm
+channel-type.airq.cnt10.label = Feinstaub >10 μm
+channel-type.airq.cnt10.description = Feinstaubpartikel grösser 10 μm
+channel-type.airq.co2.label = CO2
+channel-type.airq.co2.description = CO2
+channel-type.airq.dco2dt.label = Änderung CO2-Wert
+channel-type.airq.dco2dt.description = Änderung CO2-Wert
+channel-type.airq.dhdt.label = Feuchtigkeitsänderung
+channel-type.airq.dhdt.description = Feuchtigkeitsänderung
+channel-type.airq.dewpt.label = Taupunkt
+channel-type.airq.dewpt.description = Taupunkt
+channel-type.airq.door.label = Tür (experimentell)
+channel-type.airq.door.description = Tür wurde geöffnet
+channel-type.airq.health.label = Gesundheitsindex
+channel-type.airq.health.description = Gesundheitsindex
+channel-type.airq.humidity.label = Feuchtigkeit
+channel-type.airq.humidity.description = Feuchtigkeit
+channel-type.airq.humidity_abs.label = Absolute Feuchtigkeit
+channel-type.airq.humidity_abs.description = Absolute Feuchtigkeit
+channel-type.airq.mtime.label = Messdauer
+channel-type.airq.mtime.description = Dauer eines Messzyklus
+channel-type.airq.no2.label = NO2-Konzentration
+channel-type.airq.no2.description = NO2-Konzentration
+channel-type.airq.o3.label = O3-Konzentration
+channel-type.airq.o3.description = O3-Konzentration
+channel-type.airq.oxygen.label = Sauerstoff-Konzentration
+channel-type.airq.oxygen.description = O2-Konzentration (Sauerstoff)
+channel-type.airq.performance.label = Leistung
+channel-type.airq.performance.description = Leistungsindex
+channel-type.airq.pm1.label = Feinstaubkonzentration >1μ
+channel-type.airq.pm1.description = Konzentration Feinstaub >1μ
+channel-type.airq.pm10.label = Feinstaubkonzentration >10μ
+channel-type.airq.pm10.description = Konzentration Feinstaub >10μ
+channel-type.airq.pm2_5.label = Feinstaubkonzentration >2,5μ
+channel-type.airq.pm2_5.description = Konzentration Feinstaub >2,5μ
+channel-type.airq.pressure.label = Luftdruck
+channel-type.airq.pressure.description = Luftdruck
+channel-type.airq.so2.label = SO2-Konzentration
+channel-type.airq.so2.description = SO2-Konzentration
+channel-type.airq.sound.label = Lautstärke
+channel-type.airq.sound.description = Lautstärke
+channel-type.airq.temperature.label = Temperatur
+channel-type.airq.temperature.description = Temperatur
+channel-type.airq.timestamp.label = Messzeitpunkt
+channel-type.airq.timestamp.description = Messzeitpunkt
+channel-type.airq.tvoc.label = VOC-Konzentration
+channel-type.airq.tvoc.description = Konzentration organischer Chemikalien
+channel-type.airq.uptime.label = Laufzeit air-Q
+channel-type.airq.uptime.description = Laufzeit air-Q
+
+channel-type.airq.bat_maxerr.label = Intervall Batteriestatus
+channel-type.airq.bat_maxerr.description = Intervall Stand der Batterie, sofern vorhanden
+channel-type.airq.cnt0_3_maxerr.label = Intervall Feinstaub >0,3 μm
+channel-type.airq.cnt0_3_maxerr.description = Intervall Feinstaubpartikel grösser 0,3 μm
+channel-type.airq.cnt0_5_maxerr.label = Intervall Feinstaub >0,5 μm
+channel-type.airq.cnt0_5_maxerr.description = Intervall Feinstaubpartikel grösser 0,5 μm
+channel-type.airq.cnt1_maxerr.label = Intervall Feinstaub >1,0 μm
+channel-type.airq.cnt1_maxerr.description = Intervall Feinstaubpartikel grösser 1,0 μm
+channel-type.airq.cnt2_5_maxerr.label = Intervall Feinstaub >2,5 μm
+channel-type.airq.cnt2_5_maxerr.description = Intervall Feinstaubpartikel grösser 2,5 μm
+channel-type.airq.cnt5_maxerr.label = Intervall Feinstaub >5 μm
+channel-type.airq.cnt5_maxerr.description = Intervall Feinstaubpartikel grösser 5 μm
+channel-type.airq.cnt10_maxerr.label = Intervall Feinstaub >10 μm
+channel-type.airq.cnt10_maxerr.description = Intervall Feinstaubpartikel grösser 10 μm
+channel-type.airq.co2_maxerr.label = Intervall CO2
+channel-type.airq.co2_maxerr.description = Intervall CO2
+channel-type.airq.dewpt_maxerr.label = Intervall Taupunkt
+channel-type.airq.dewpt_maxerr.description = Intervall Taupunkt
+channel-type.airq.humidity_maxerr.label = Intervall Feuchtigkeit
+channel-type.airq.humidity_maxerr.description = Intervall Feuchtigkeit
+channel-type.airq.humidity_abs_maxerr.label = Intervall Absolute Feuchtigkeit
+channel-type.airq.humidity_abs_maxerr.description = Intervall Absolute Feuchtigkeit
+channel-type.airq.no2_maxerr.label = Intervall NO2-Konzentration
+channel-type.airq.no2_maxerr.description = Intervall NO2-Konzentration
+channel-type.airq.o3_maxerr.label = Intervall O3-Konzentration
+channel-type.airq.o3_maxerr.description = Intervall O3-Konzentration
+channel-type.airq.oxygen_maxerr.label = Intervall Sauerstoff-Konzentration
+channel-type.airq.oxygen_maxerr.description = Intervall O2-Konzentration (Sauerstoff)
+channel-type.airq.pm1_maxerr.label = Intervall Feinstaubkonzentration >1μ
+channel-type.airq.pm1_maxerr.description = Intervall Konzentration Feinstaub >1μ
+channel-type.airq.pm10_maxerr.label = Intervall Feinstaubkonzentration >10μ
+channel-type.airq.pm10_maxerr.description = Intervall Konzentration Feinstaub >10μ
+channel-type.airq.pm2_5_maxerr.label = Intervall Feinstaubkonzentration >2,5μ
+channel-type.airq.pm2_5_maxerr.description = Intervall Konzentration Feinstaub >2,5μ
+channel-type.airq.pressure_maxerr.label = Intervall Luftdruck
+channel-type.airq.pressure_maxerr.description = Intervall Luftdruck
+channel-type.airq.so2_maxerr.label = Intervall SO2-Konzentration
+channel-type.airq.so2_maxerr.description = Intervall SO2-Konzentration
+channel-type.airq.sound_maxerr.label = Intervall Lautstärke
+channel-type.airq.sound_maxerr.description = Intervall Lautstärke
+channel-type.airq.temperature_maxerr.label = Intervall Temperatur
+channel-type.airq.temperature_maxerr.description = Intervall Temperatur
+channel-type.airq.tvoc_maxerr.label = Intervall VOC-Konzentration
+channel-type.airq.tvoc_maxerr.description = Intervall Konzentration organischer Chemikalien
+
diff --git a/bundles/org.openhab.binding.airq/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.airq/src/main/resources/OH-INF/thing/thing-types.xml
new file mode 100644
index 000000000..47d5b2b6b
--- /dev/null
+++ b/bundles/org.openhab.binding.airq/src/main/resources/OH-INF/thing/thing-types.xml
@@ -0,0 +1,666 @@
+
+
+
+
+
+ Thing for air-Q Device
+ Sensor
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Unknown Device ID
+ Unknown Hardware version
+ Unknown Software version
+ Unknown sensor list
+ No info about sensors
+ No industry info
+
+
+
+
+ network-address
+
+ IP Network Address where air-Q Can Be Reached.
+
+
+ password
+
+ Password of air-Q Device.
+
+
+
+
+
+
+ String
+
+
+
+
+
+ Number:Length
+
+
+
+
+
+ Number:Dimensionless
+
+
+
+
+
+ Number:Dimensionless
+
+
+
+
+
+ Number:Dimensionless
+
+
+
+
+
+ Number:Dimensionless
+
+
+
+
+
+ Number:Dimensionless
+
+
+
+
+
+ Number:Dimensionless
+
+
+
+
+
+ Number
+
+
+
+
+
+ Number:Dimensionless
+
+
+
+
+
+ Number
+
+
+
+
+
+ Number
+
+
+
+
+
+ Number:Temperature
+
+
+
+
+
+ Number
+
+
+
+
+
+ Number:Dimensionless
+
+
+
+
+
+ Number:Dimensionless
+
+
+
+
+
+ Number
+
+
+
+
+
+ Number:Time
+
+
+
+
+
+ Number
+
+
+
+
+
+ Number
+
+
+
+
+
+ Number:Dimensionless
+
+
+
+
+
+ Number:Dimensionless
+
+
+
+
+
+ Number
+
+
+
+
+
+ Number
+
+
+
+
+
+ Number
+
+
+
+
+
+ Number:Pressure
+
+
+
+
+
+ Number
+
+
+
+
+
+ Number:Dimensionless
+
+
+
+
+
+ Number:Temperature
+
+
+
+
+
+ DateTime
+
+
+
+
+
+ Number:Dimensionless
+
+
+
+
+
+ Number:Time
+
+
+
+
+
+
+ Number:Dimensionless
+
+
+
+
+
+ Number:Dimensionless
+
+
+
+
+
+ Number:Dimensionless
+
+
+
+
+
+ Number:Dimensionless
+
+
+
+
+
+ Number:Dimensionless
+
+
+
+
+
+ Number:Dimensionless
+
+
+
+
+
+ Number:Dimensionless
+
+
+
+
+
+ Number:Dimensionless
+
+
+
+
+
+ Number:Dimensionless
+
+
+
+
+
+ Number:Dimensionless
+
+
+
+
+
+ Number:Dimensionless
+
+
+
+
+
+ Number:Dimensionless
+
+
+
+
+
+ Number:Dimensionless
+
+
+
+
+
+ Number:Dimensionless
+
+
+
+
+
+ Number:Dimensionless
+
+
+
+
+
+ Number:Dimensionless
+
+
+
+
+
+ Number:Dimensionless
+
+
+
+
+
+ Number:Dimensionless
+
+
+
+
+
+ Number:Dimensionless
+
+
+
+
+
+ Number:Dimensionless
+
+
+
+
+
+ Number:Dimensionless
+
+
+
+
+
+ Number:Dimensionless
+
+
+
+
+
+
+
+ Switch
+
+
+
+
+
+ String
+
+
+
+
+
+ String
+
+
+
+
+
+ Switch
+
+
+
+
+ String
+
+
+
+
+ Location
+
+
+
+
+ String
+
+
+
+
+ String
+
+
+
+
+ Number:Dimensionless
+
+
+
+
+ Number:Dimensionless
+
+
+
+
+ Switch
+
+
+
+
+ Switch
+
+
+
+
+ String
+
+
+
+
+
+ String
+
+
+
+
+ String
+
+
+
+
+ String
+
+
+
+
+
+ Switch
+
+
+
+
+ String
+
+
+
+
+
+ String
+
+
+
+
+
+ String
+
+
+
+
+
+ String
+
+
+
+
+
+ String
+
+
+
+
+
+ String
+
+
+
+
+
+ Switch
+
+
+
+
+ Number
+
+
+
+
+
+ String
+
+
+
+
+ Switch
+
+
+
+
+ Switch
+
+
+
+
+ Switch
+
+
+
+
+ Switch
+
+
+
+
+ Switch
+
+
+
+
+ Switch
+
+
+
+
+ Switch
+
+
+
+
+ String
+
+
+
+
+
+ Switch
+
+
+
+
+
+ Switch
+
+
+
+
+ Switch
+
+
+
+
+ Switch
+
+
+
+
diff --git a/bundles/pom.xml b/bundles/pom.xml
index 0e0b3f6e7..bf2fe504d 100644
--- a/bundles/pom.xml
+++ b/bundles/pom.xml
@@ -41,6 +41,7 @@
org.openhab.transform.xslt
org.openhab.binding.adorne
+ org.openhab.binding.airq
org.openhab.binding.airquality
org.openhab.binding.airvisualnode
org.openhab.binding.alarmdecoder