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