From 315964a55a74d3998b3ef1fbcf87d5d2b00ecbd9 Mon Sep 17 00:00:00 2001 From: aurelio1 Date: Thu, 29 Apr 2021 18:23:35 +0200 Subject: [PATCH] [airq] Air-Q binding Initial contribution (#10048) Signed-off-by: Aurelio Caliaro --- CODEOWNERS | 1 + bom/openhab-addons/pom.xml | 5 + bundles/org.openhab.binding.airq/NOTICE | 13 + bundles/org.openhab.binding.airq/README.md | 223 +++++ .../doc/image_air-Q.png | Bin 0 -> 37498 bytes bundles/org.openhab.binding.airq/pom.xml | 17 + .../src/main/feature/feature.xml | 9 + .../airq/internal/AirqBindingConstants.java | 28 + .../airq/internal/AirqConfiguration.java | 27 + .../binding/airq/internal/AirqHandler.java | 789 ++++++++++++++++++ .../airq/internal/AirqHandlerFactory.java | 62 ++ .../main/resources/OH-INF/binding/binding.xml | 11 + .../OH-INF/i18n/airq_de_DE.properties | 125 +++ .../resources/OH-INF/thing/thing-types.xml | 666 +++++++++++++++ bundles/pom.xml | 1 + 15 files changed, 1977 insertions(+) create mode 100644 bundles/org.openhab.binding.airq/NOTICE create mode 100644 bundles/org.openhab.binding.airq/README.md create mode 100644 bundles/org.openhab.binding.airq/doc/image_air-Q.png create mode 100644 bundles/org.openhab.binding.airq/pom.xml create mode 100644 bundles/org.openhab.binding.airq/src/main/feature/feature.xml create mode 100644 bundles/org.openhab.binding.airq/src/main/java/org/openhab/binding/airq/internal/AirqBindingConstants.java create mode 100644 bundles/org.openhab.binding.airq/src/main/java/org/openhab/binding/airq/internal/AirqConfiguration.java create mode 100644 bundles/org.openhab.binding.airq/src/main/java/org/openhab/binding/airq/internal/AirqHandler.java create mode 100644 bundles/org.openhab.binding.airq/src/main/java/org/openhab/binding/airq/internal/AirqHandlerFactory.java create mode 100644 bundles/org.openhab.binding.airq/src/main/resources/OH-INF/binding/binding.xml create mode 100644 bundles/org.openhab.binding.airq/src/main/resources/OH-INF/i18n/airq_de_DE.properties create mode 100644 bundles/org.openhab.binding.airq/src/main/resources/OH-INF/thing/thing-types.xml 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 0000000000000000000000000000000000000000..fcc7240e30d8e1e27b989bf5c9cd212a95fd53c5 GIT binary patch literal 37498 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 z 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 + + + 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