From 9af295c0dea8e5e04cab94ef5c2e0314b93a2e5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B8rgen=20Austvik?= Date: Fri, 10 May 2024 00:10:37 +0200 Subject: [PATCH] [airgradient] Initial contribution (#16584) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [airgradient] Initial contribution AirGradient are open source and open hardware air quality sensors that you can read values from through a cloud API or directly from the device. Signed-off-by: Jørgen Austvik Signed-off-by: Ciprian Pascu --- CODEOWNERS | 1 + bom/openhab-addons/pom.xml | 5 + .../org.openhab.binding.airgradient/NOTICE | 13 + .../org.openhab.binding.airgradient/README.md | 100 +++++++ .../doc/airgradient_sensors.png | Bin 0 -> 111593 bytes .../org.openhab.binding.airgradient/pom.xml | 17 ++ .../src/main/feature/feature.xml | 9 + .../internal/AirGradientBindingConstants.java | 77 +++++ .../internal/AirGradientHandlerFactory.java | 87 ++++++ .../AirGradientCommunicationException.java | 33 +++ .../communication/JsonParserHelper.java | 67 +++++ .../communication/PrometheusParserHelper.java | 93 +++++++ .../internal/communication/RESTHelper.java | 75 +++++ .../communication/RemoteAPIController.java | 146 ++++++++++ .../config/AirGradientAPIConfiguration.java | 58 ++++ .../AirGradientLocationConfiguration.java | 26 ++ .../AirGradientLocationDiscoveryService.java | 154 ++++++++++ .../AirGradientMDNSDiscoveryParticipant.java | 122 ++++++++ .../handler/AirGradientAPIHandler.java | 199 +++++++++++++ .../handler/AirGradientLocalHandler.java | 183 ++++++++++++ .../handler/AirGradientLocationHandler.java | 160 +++++++++++ .../internal/handler/MeasureHelper.java | 100 +++++++ .../internal/handler/PollEventListener.java | 34 +++ .../airgradient/internal/model/LedMode.java | 27 ++ .../airgradient/internal/model/Measure.java | 165 +++++++++++ .../internal/prometheus/PrometheusMetric.java | 124 +++++++++ .../prometheus/PrometheusTextParser.java | 44 +++ .../src/main/resources/OH-INF/addon/addon.xml | 24 ++ .../OH-INF/i18n/airgradient.properties | 50 ++++ .../resources/OH-INF/thing/thing-types.xml | 202 ++++++++++++++ .../AirGradientHandlerFactoryTest.java | 74 +++++ .../handler/AirGradientAPIHandlerTest.java | 119 ++++++++ .../AirGradientLocationHandlerTest.java | 105 +++++++ .../handler/RemoteApiControllerTest.java | 262 ++++++++++++++++++ .../prometheus/PrometheusMetricTest.java | 77 +++++ bundles/pom.xml | 1 + 36 files changed, 3033 insertions(+) create mode 100644 bundles/org.openhab.binding.airgradient/NOTICE create mode 100644 bundles/org.openhab.binding.airgradient/README.md create mode 100644 bundles/org.openhab.binding.airgradient/doc/airgradient_sensors.png create mode 100644 bundles/org.openhab.binding.airgradient/pom.xml create mode 100644 bundles/org.openhab.binding.airgradient/src/main/feature/feature.xml create mode 100644 bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/AirGradientBindingConstants.java create mode 100644 bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/AirGradientHandlerFactory.java create mode 100644 bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/communication/AirGradientCommunicationException.java create mode 100644 bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/communication/JsonParserHelper.java create mode 100644 bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/communication/PrometheusParserHelper.java create mode 100644 bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/communication/RESTHelper.java create mode 100644 bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/communication/RemoteAPIController.java create mode 100644 bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/config/AirGradientAPIConfiguration.java create mode 100644 bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/config/AirGradientLocationConfiguration.java create mode 100644 bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/discovery/AirGradientLocationDiscoveryService.java create mode 100644 bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/discovery/AirGradientMDNSDiscoveryParticipant.java create mode 100644 bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/AirGradientAPIHandler.java create mode 100644 bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/AirGradientLocalHandler.java create mode 100644 bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/AirGradientLocationHandler.java create mode 100644 bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/MeasureHelper.java create mode 100644 bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/PollEventListener.java create mode 100644 bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/model/LedMode.java create mode 100644 bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/model/Measure.java create mode 100644 bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/prometheus/PrometheusMetric.java create mode 100644 bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/prometheus/PrometheusTextParser.java create mode 100644 bundles/org.openhab.binding.airgradient/src/main/resources/OH-INF/addon/addon.xml create mode 100644 bundles/org.openhab.binding.airgradient/src/main/resources/OH-INF/i18n/airgradient.properties create mode 100644 bundles/org.openhab.binding.airgradient/src/main/resources/OH-INF/thing/thing-types.xml create mode 100644 bundles/org.openhab.binding.airgradient/src/test/java/org/openhab/binding/airgradient/internal/AirGradientHandlerFactoryTest.java create mode 100644 bundles/org.openhab.binding.airgradient/src/test/java/org/openhab/binding/airgradient/internal/handler/AirGradientAPIHandlerTest.java create mode 100644 bundles/org.openhab.binding.airgradient/src/test/java/org/openhab/binding/airgradient/internal/handler/AirGradientLocationHandlerTest.java create mode 100644 bundles/org.openhab.binding.airgradient/src/test/java/org/openhab/binding/airgradient/internal/handler/RemoteApiControllerTest.java create mode 100644 bundles/org.openhab.binding.airgradient/src/test/java/org/openhab/binding/airgradient/internal/prometheus/PrometheusMetricTest.java diff --git a/CODEOWNERS b/CODEOWNERS index 274522c5055..0d7664c1f9c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -14,6 +14,7 @@ /bundles/org.openhab.automation.pwm/ @fwolter /bundles/org.openhab.binding.adorne/ @theiding /bundles/org.openhab.binding.ahawastecollection/ @soenkekueper +/bundles/org.openhab.binding.airgradient/ @austvik /bundles/org.openhab.binding.airq/ @aurelio1 @fwolter /bundles/org.openhab.binding.airquality/ @openhab/add-ons-maintainers /bundles/org.openhab.binding.airvisualnode/ @3cky diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml index 3db66110ec2..e2649c4cda5 100644 --- a/bom/openhab-addons/pom.xml +++ b/bom/openhab-addons/pom.xml @@ -61,6 +61,11 @@ org.openhab.binding.ahawastecollection ${project.version} + + org.openhab.addons.bundles + org.openhab.binding.airgradient + ${project.version} + org.openhab.addons.bundles org.openhab.binding.airq diff --git a/bundles/org.openhab.binding.airgradient/NOTICE b/bundles/org.openhab.binding.airgradient/NOTICE new file mode 100644 index 00000000000..38d625e3492 --- /dev/null +++ b/bundles/org.openhab.binding.airgradient/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.airgradient/README.md b/bundles/org.openhab.binding.airgradient/README.md new file mode 100644 index 00000000000..2a901f9885f --- /dev/null +++ b/bundles/org.openhab.binding.airgradient/README.md @@ -0,0 +1,100 @@ +# AirGradient Binding + +AirGradient provides open source and open hardware air quality monitors. + +This binding reads air quality data from the AirGradient (https://www.airgradient.com/) API. + +This API is documented at https://api.airgradient.com/public/docs/api/v1/ + +## Supported Things + +![AirGradient sensors](doc/airgradient_sensors.png) + +This binding supports all the different AirGradient sensors, providing most of the sensor data. + +- `bridge`: Connection to the API +- `location`: Location in the API to read values for + +## Discovery + +Autodiscovery of locations is implemented. +Start by adding an AirGradient API thing. +When that is added and online, run a scan for new things in the AirGradient binding. + +## Thing Configuration + +This binding supports reading data both directly from AirGradient sensors and from the AirGradient API. +If you don't specify any path on the server, the binding will behave as if the hostname is the hostname of the AirGradient API server, and append paths and tokens for it. + +The binding will adapt to the content type of the returned content to support different formats for getting data both from local and cloud installations. + +| Name | Hostname | Content-Type | Parser | +|-------------------|-----------------------------------------------------------------|------------------------------|--------| +| API | Hostnames without any path (e.g., https://api.airgradient.com/) | application/json | JSON parser for the AirGradient API, correct paths will be appended to the calls | +| Local OpenMetrics | Hostnames with path (e.g., http://192.168.x.x/metrics) | application/openmetrics-text | OpenMetrics parser | +| Local Web | Hostnames with path (e.g., http://192.168.x.x/measures/current) | application/json | JSON parser for the AirGradient API, as if you returned the value of sendToServer() payload | +| Local Prometheus | Hostnames with path (e.g., http://192.168.x.x/measures) | text/plain | Prometheus parser for [Prometheus format](https://prometheus.io/docs/instrumenting/exposition_formats/) | + +### AirGradient API + +The connection to the API needs setup and configuration + +1. Log in to the AirGradient Dashboard: https://app.airgradient.com/dashboard +2. Navigate to Place->Connectivity Settings from the upper left hamburger menu. +3. Enable API access, and take a copy of the Token, which will be used in the token setting to configure the connection to the API. + +To add a location, you need to know the location ID. To get the location ID, you + +1. Log in to the AirGradient Dashboard: https://app.airgradient.com/dashboard +2. Navigate to Locations from the upper left hamburger menu. +3. Here you will find a list of all of your sensors, with a location ID in the left column. Use that id when you add new Location things. + +### `API` Thing Configuration + +| Name | Type | Description | Default | Required | Advanced | +|-----------------|---------|---------------------------------------|------------------------------|----------|----------| +| token | text | Token to access the device | N/A | yes | no | +| hostname | text | Hostname or IP address of the API | https://api.airgradient.com/ | no | yes | +| refreshInterval | integer | Interval the device is polled in sec. | 600 | no | yes | + +### `Location` Thing Configuration + +| Name | Type | Description | Default | Required | Advanced | +|-----------------|---------|-------------------------------------------------------------------|---------|----------|----------| +| location | text | A number identifying the location id in the AirGradient Dashboard | N/A | yes | no | + +## Channels + +For more information about the data in the channels, please refer to the models in https://api.airgradient.com/public/docs/api/v1/ + +| Channel | Type | Read/Write | Description | +|-------------|----------------------|------------|----------------------------------------------------------------------------------| +| pm01 | Number:Density | Read | Particulate Matter 1 (0.001mm) | +| pm02 | Number:Density | Read | Particulate Matter 2 (0.002mm) | +| pm10 | Number:Density | Read | Particulate Matter 10 (0.01mm) | +| pm003-count | Switch | Read | The number of particles with a diameter beyond 0.3 microns in 1 deciliter of air | +| rco2 | Number:Density | Read | Carbon dioxide PPM | +| tvoc | Number:Density | Read | Total Volatile Organic Compounds | +| atmp | Number:Temperature | Read | Ambient Temperature | +| rhum | Number:Dimensionless | Read | Relative Humidity Percentage | +| wifi | Number | Read | Received signal strength indicator | +| boot | Number:Dimensionless | Read | Number of measure uploads since last reboot (boot) | + +## Full Example + +### Thing Configuration + +```java +Bridge airgradient:airgradient-api:home "My Home" [ token="abc123...." ] { + Thing location "654321" "Outside" [ location="654321" ] +} +``` + +### Item Configuration + +```java +Number:Density AirGradient_Location_PM2 "%.0f kg/m³" {channel="airgradient:location:654321:pm2"}" +Number:Temperature AirGradient_Location_PM2 "Temperature [%.1f °C]" {channel="airgradient:location:654321:atmp"}" +``` + + diff --git a/bundles/org.openhab.binding.airgradient/doc/airgradient_sensors.png b/bundles/org.openhab.binding.airgradient/doc/airgradient_sensors.png new file mode 100644 index 0000000000000000000000000000000000000000..146edf1c8e1d3c81a3b2e158816c35a6d66f785e GIT binary patch literal 111593 zcma&MQ*b40@a`LHVq*m>wy|Q{n0R6*6LVtQb|#!?Vrycr*tTt-`p*CCQ>RYti~W16 z@1A+X2ozZv2{i}^C;$WmWB~#k1O&tmN%s{51Z0S{xVVa} zxHy@kvx9}TojC*qZK7|YpiI9AV9-!~P>mkd9o=a}C6_TO3S@&5r2?Ess^-hke}z>f zOv1*K?rqxM9yVF%9n{f+*Ahj4hj4OpQgcnj{7T2x9q@EqkN0>Y@wkHct!kXLz=dkn z{g$e^izshf{lME@pe zP9BeYz*-}PLS6ewZKXiJIs{&4E$P{kmZ6D+waaZl zz=!{nNvvP;-6TFezu4la{`7<3wRcWnwGVcJfPjEAk(ZW$`1&(H9S|(yH?`OR&uwVc*ncy zEcf%i>umh!s#`HjKHr(8l$7JZHi|)3#YOh)#m^8H;qL6j|Aq4tI_&~}oYn4SFyQ?# z+^d$0P$%PNBB7a=1K?!oVCSCv!&_66xsJty_vm?NoroYYv&mJ<&BJ{01s5u{&dh9p z_vzhlYl}I@B`)CqAs(HbQB>rAAOk@D4}p#_|BF5h^#1`t`v0%`|H%Gd)&EBJfBcyb zcC9fY)c*|$n`;5yBmSMU5sFM`uAsfJfThdR3k11-2&5(cCD!NL(dYa66hso~%@sV1 z0AEPuPe@)?NU4DSwp#N4Ng*DJk1;T(0ZOeP7zS4uNGlCNB3kkBIRz+R`ILJ7s6!a@#k7AD z2Ilqch9wk^a}nE4L3+K@3kwExxWh7QH^2dAvF@|6m}_*D&MW2m+tlPi*|j{3NhTT|6j+8m&X^+Ey|Tv_{%sRM&l z;k8}vFa0(iFmF|prSnf{sGVmRrbsVQkNauxB}vyM1kjjIak;DE46P-Qk;4FiP&!Gs z{o{u5s(&B#Ta2{&P!XMM(qu^gnM3EL{}Nve8*Mez34-T)TnNs6JH=mvEm0)=IQ7!Z zr!Ti(;_Q3JPvMlKiS?t-#}85*2Q4jI2ajNqOeb0xxpIQK1U2JWVRmA{rR!}ftTMSOk;{Gzpwq(I^80o{YJZD z&lbE7U0s8UrO0Lg-m?>%2{&83nN%SSVn%8=opQ#Ai;Bx$b-Z4u9wXZE_B z-^=4$=iQ~lb9B!uFf-WyhUF{6&q3j+VCt=>yUz^C3r_gCH-3~On9=o%|LQaAmrEqi z<|_`|vhe->4;j!ga)O*ujuGeR$s;u~HQb&NNUj!~9Nljp|LGWnEo~4xN=Y#ZXbWXn zvWH;Br$T;3)Ir+2$>7=a3EtS)Ajpg_Jf64^FXP!B0{{HgkKr9o{CBf;(1>%VV9wm* zD*$Yq{#0XN%GspA?)i1NEj83JVn5Aq(~J>u1xMVggCf1Ev+cltuzAWo~PbpsZESyb

0~Px=ou04szL&W<6QBRlPw8Si78NY8iNix_6BJ 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 R`gQt4&z{{q}M?STLQ literal 0 HcmV?d00001 diff --git a/bundles/org.openhab.binding.airgradient/pom.xml b/bundles/org.openhab.binding.airgradient/pom.xml new file mode 100644 index 00000000000..69395bc5805 --- /dev/null +++ b/bundles/org.openhab.binding.airgradient/pom.xml @@ -0,0 +1,17 @@ + + + + 4.0.0 + + + org.openhab.addons.bundles + org.openhab.addons.reactor.bundles + 4.2.0-SNAPSHOT + + + org.openhab.binding.airgradient + + openHAB Add-ons :: Bundles :: AirGradient Binding + + diff --git a/bundles/org.openhab.binding.airgradient/src/main/feature/feature.xml b/bundles/org.openhab.binding.airgradient/src/main/feature/feature.xml new file mode 100644 index 00000000000..e77f718eba6 --- /dev/null +++ b/bundles/org.openhab.binding.airgradient/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.airgradient/${project.version} + + diff --git a/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/AirGradientBindingConstants.java b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/AirGradientBindingConstants.java new file mode 100644 index 00000000000..839e5d7c6ba --- /dev/null +++ b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/AirGradientBindingConstants.java @@ -0,0 +1,77 @@ +/** + * Copyright (c) 2010-2024 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.airgradient.internal; + +import java.time.Duration; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.thing.ThingTypeUID; + +/** + * The {@link AirGradientBindingConstants} class defines common constants, which are + * used across the whole binding. + * + * @author Jørgen Austvik - Initial contribution + */ +@NonNullByDefault +public class AirGradientBindingConstants { + + private static final String BINDING_ID = "airgradient"; + + // List of all Thing Type UIDs + public static final ThingTypeUID THING_TYPE_API = new ThingTypeUID(BINDING_ID, "airgradient-api"); + public static final ThingTypeUID THING_TYPE_LOCAL = new ThingTypeUID(BINDING_ID, "airgradient-local"); + public static final ThingTypeUID THING_TYPE_LOCATION = new ThingTypeUID(BINDING_ID, "location"); + + // List of all Channel ids + public static final String CHANNEL_PM_01 = "pm01"; + public static final String CHANNEL_PM_02 = "pm02"; + public static final String CHANNEL_PM_10 = "pm10"; + public static final String CHANNEL_PM_003_COUNT = "pm003-count"; + public static final String CHANNEL_ATMP = "atmp"; + public static final String CHANNEL_RHUM = "rhum"; + public static final String CHANNEL_WIFI = "wifi"; + public static final String CHANNEL_RCO2 = "rco2"; + public static final String CHANNEL_TVOC = "tvoc"; + public static final String CHANNEL_LEDS_MODE = "leds"; + public static final String CHANNEL_CALIBRATION = "calibration"; + public static final String CHANNEL_UPLOADS_SINCE_BOOT = "uploads-since-boot"; + + // List of all properties + public static final String PROPERTY_NAME = "name"; + + // All configurations + public static final String CONFIG_LOCATION = "location"; + public static final String CONFIG_API_TOKEN = "token"; + public static final String CONFIG_API_HOST_NAME = "hostname"; + public static final String CONFIG_API_REFRESH_INTERVAL = "refreshInterval"; + + // URLs for API + public static final String CURRENT_MEASURES_PATH = "/public/api/v1/locations/measures/current?token=%s"; + public static final String CURRENT_MEASURES_LOCAL_PATH = "/measures/current"; + public static final String LEDS_MODE_PATH = "/public/api/v1/sensors/%s/config/leds/mode?token=%s"; + public static final String CALIBRATE_CO2_PATH = "/public/api/v1/sensors/%s/co2/calibration?token=%s"; + + // Discovery + public static final Duration SEARCH_TIME = Duration.ofSeconds(15); + public static final boolean BACKGROUND_DISCOVERY = true; + public static final Duration DEFAULT_POLL_INTERVAL_LOCAL = Duration.ofSeconds(10); + + // Media types + public static final String CONTENTTYPE_JSON = "application/json"; + public static final String CONTENTTYPE_TEXT = "text/plain"; + public static final String CONTENTTYPE_OPENMETRICS = "application/openmetrics-text"; + + // Communication + public static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(10); +} diff --git a/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/AirGradientHandlerFactory.java b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/AirGradientHandlerFactory.java new file mode 100644 index 00000000000..75225742a19 --- /dev/null +++ b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/AirGradientHandlerFactory.java @@ -0,0 +1,87 @@ +/** + * Copyright (c) 2010-2024 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.airgradient.internal; + +import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.*; + +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.openhab.binding.airgradient.internal.handler.AirGradientAPIHandler; +import org.openhab.binding.airgradient.internal.handler.AirGradientLocalHandler; +import org.openhab.binding.airgradient.internal.handler.AirGradientLocationHandler; +import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.binding.BaseThingHandlerFactory; +import org.openhab.core.thing.binding.ThingHandler; +import org.openhab.core.thing.binding.ThingHandlerFactory; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link AirGradientHandlerFactory} is responsible for creating things and thing + * handlers. + * + * @author Jørgen Austvik - Initial contribution + */ +@NonNullByDefault +@Component(configurationPid = "binding.airgradient", service = ThingHandlerFactory.class) +public class AirGradientHandlerFactory extends BaseThingHandlerFactory { + + private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_API, THING_TYPE_LOCATION, + THING_TYPE_LOCAL); + + private final Logger logger = LoggerFactory.getLogger(AirGradientHandlerFactory.class); + private final HttpClient httpClient; + + @Activate + public AirGradientHandlerFactory(final @Reference HttpClientFactory factory) { + logger.debug("Activating factory for: {}", SUPPORTED_THING_TYPES_UIDS); + this.httpClient = factory.getCommonHttpClient(); + } + + @Override + public boolean supportsThingType(ThingTypeUID thingTypeUID) { + logger.debug("We support: {}", SUPPORTED_THING_TYPES_UIDS); + return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID); + } + + @Override + protected @Nullable ThingHandler createHandler(Thing thing) { + ThingTypeUID thingTypeUID = thing.getThingTypeUID(); + + if (THING_TYPE_API.equals(thingTypeUID)) { + logger.debug("Creating Bridge Handler for {}", thingTypeUID); + return new AirGradientAPIHandler((Bridge) thing, httpClient); + } + + if (THING_TYPE_LOCATION.equals(thingTypeUID)) { + logger.debug("Creating Location Handler for {}", thingTypeUID); + return new AirGradientLocationHandler(thing); + } + + if (THING_TYPE_LOCAL.equals(thingTypeUID)) { + logger.debug("Creating Local Handler for {}", thingTypeUID); + return new AirGradientLocalHandler(thing, httpClient); + } + + return null; + } +} diff --git a/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/communication/AirGradientCommunicationException.java b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/communication/AirGradientCommunicationException.java new file mode 100644 index 00000000000..2c6adba8fb5 --- /dev/null +++ b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/communication/AirGradientCommunicationException.java @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2010-2024 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.airgradient.internal.communication; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Exception for communication errors against AirGradient API or sensors. + * + * @author Jørgen Austvik - Initial contribution + */ +@NonNullByDefault +public class AirGradientCommunicationException extends Exception { + private static final long serialVersionUID = 1L; + + public AirGradientCommunicationException(String message) { + super(message); + } + + public AirGradientCommunicationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/communication/JsonParserHelper.java b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/communication/JsonParserHelper.java new file mode 100644 index 00000000000..8138ff6c54b --- /dev/null +++ b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/communication/JsonParserHelper.java @@ -0,0 +1,67 @@ +/** + * Copyright (c) 2010-2024 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.airgradient.internal.communication; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.airgradient.internal.model.Measure; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; + +/** + * Helper for parsing JSON. + * + * @author Jørgen Austvik - Initial contribution + */ +@NonNullByDefault +public class JsonParserHelper { + + public static List parseJson(Gson gson, String stringResponse) { + List<@Nullable Measure> measures = null; + if (stringResponse.startsWith("[")) { + // Array of measures, like returned from the AirGradients API + Type measuresType = new TypeToken>() { + }.getType(); + measures = gson.fromJson(stringResponse, measuresType); + } else if (stringResponse.startsWith("{")) { + // Single measure e.g. if you read directly from the device + Type measureType = new TypeToken() { + }.getType(); + Measure measure = gson.fromJson(stringResponse, measureType); + measures = new ArrayList<>(1); + measures.add(measure); + } + + if (measures != null) { + List<@Nullable Measure> nullableMeasuresWithoutNulls = measures.stream().filter(Objects::nonNull).toList(); + List measuresWithoutNulls = new ArrayList<>(nullableMeasuresWithoutNulls.size()); + for (@Nullable + Measure m : nullableMeasuresWithoutNulls) { + if (m != null) { + measuresWithoutNulls.add(m); + } + } + + return measuresWithoutNulls; + } + + return Collections.emptyList(); + } +} diff --git a/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/communication/PrometheusParserHelper.java b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/communication/PrometheusParserHelper.java new file mode 100644 index 00000000000..06fbc54b4cf --- /dev/null +++ b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/communication/PrometheusParserHelper.java @@ -0,0 +1,93 @@ +/** + * Copyright (c) 2010-2024 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.airgradient.internal.communication; + +import java.util.Arrays; +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.airgradient.internal.model.Measure; +import org.openhab.binding.airgradient.internal.prometheus.PrometheusMetric; +import org.openhab.binding.airgradient.internal.prometheus.PrometheusTextParser; + +/** + * Helper for parsing Prometheus data. + * + * @author Jørgen Austvik - Initial contribution + */ +@NonNullByDefault +public class PrometheusParserHelper { + + public static List parsePrometheus(String stringResponse) { + List metrics = PrometheusTextParser.parse(stringResponse); + Measure measure = new Measure(); + + for (PrometheusMetric metric : metrics) { + if (metric.getMetricName().equals("pm01")) { + measure.pm01 = metric.getValue(); + } else if (metric.getMetricName().equals("pm02")) { + measure.pm02 = metric.getValue(); + } else if (metric.getMetricName().equals("pm10")) { + measure.pm10 = metric.getValue(); + } else if (metric.getMetricName().equals("rco2")) { + measure.rco2 = metric.getValue(); + } else if (metric.getMetricName().equals("atmp")) { + measure.atmp = metric.getValue(); + } else if (metric.getMetricName().equals("rhum")) { + measure.rhum = metric.getValue(); + } else if (metric.getMetricName().equals("tvoc")) { + measure.tvoc = metric.getValue(); + } else if (metric.getMetricName().equals("nox")) { + measure.noxIndex = metric.getValue(); + } else if (metric.getMetricName().equals("airgradient_wifi_rssi_dbm")) { + measure.wifi = metric.getValue(); + } else if (metric.getMetricName().equals("airgradient_co2_ppm")) { + measure.rco2 = metric.getValue(); + } else if (metric.getMetricName().equals("airgradient_pm1_ugm3")) { + measure.pm01 = metric.getValue(); + } else if (metric.getMetricName().equals("airgradient_pm2d5_ugm3")) { + measure.pm02 = metric.getValue(); + } else if (metric.getMetricName().equals("airgradient_pm10_ugm3")) { + measure.pm10 = metric.getValue(); + } else if (metric.getMetricName().equals("airgradient_pm0d3_p100ml")) { + measure.pm003Count = metric.getValue(); + } else if (metric.getMetricName().equals("airgradient_tvoc_index")) { + measure.tvoc = metric.getValue(); + } else if (metric.getMetricName().equals("airgradient_tvoc_raw_index")) { + measure.tvocIndex = metric.getValue(); + } else if (metric.getMetricName().equals("airgradient_nox_index")) { + measure.noxIndex = metric.getValue(); + } else if (metric.getMetricName().equals("airgradient_temperature_degc")) { + measure.atmp = metric.getValue(); + } else if (metric.getMetricName().equals("airgradient_humidity_percent")) { + measure.rhum = metric.getValue(); + } + + if (metric.getLabels().containsKey("id")) { + String id = metric.getLabels().get("id"); + measure.serialno = id; + measure.locationId = id; + measure.locationName = id; + } + + if (metric.getLabels().containsKey("airgradient_serial_number")) { + String id = metric.getLabels().get("airgradient_serial_number"); + measure.serialno = id; + measure.locationId = id; + measure.locationName = id; + } + } + + return Arrays.asList(measure); + } +} diff --git a/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/communication/RESTHelper.java b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/communication/RESTHelper.java new file mode 100644 index 00000000000..a522e1aa06d --- /dev/null +++ b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/communication/RESTHelper.java @@ -0,0 +1,75 @@ +/** + * Copyright (c) 2010-2024 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.airgradient.internal.communication; + +import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.CALIBRATE_CO2_PATH; +import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.CURRENT_MEASURES_PATH; +import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.LEDS_MODE_PATH; +import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.REQUEST_TIMEOUT; + +import java.util.concurrent.TimeUnit; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.api.Request; +import org.eclipse.jetty.http.HttpMethod; +import org.openhab.binding.airgradient.internal.config.AirGradientAPIConfiguration; + +/** + * Helper for doing rest calls to the API. + * + * @author Jørgen Austvik - Initial contribution + */ +@NonNullByDefault +public class RESTHelper { + + public static @Nullable String generateMeasuresUrl(AirGradientAPIConfiguration apiConfig) { + if (apiConfig.hasCloudUrl()) { + return apiConfig.hostname + String.format(CURRENT_MEASURES_PATH, apiConfig.token); + } else { + return apiConfig.hostname; + } + } + + public static @Nullable String generateCalibrationCo2Url(AirGradientAPIConfiguration apiConfig, String serialNo) { + if (apiConfig.hasCloudUrl()) { + return apiConfig.hostname + String.format(CALIBRATE_CO2_PATH, serialNo, apiConfig.token); + } else { + return apiConfig.hostname; + } + } + + public static @Nullable String generateGetLedsModeUrl(AirGradientAPIConfiguration apiConfig, String serialNo) { + if (apiConfig.hasCloudUrl()) { + return apiConfig.hostname + String.format(LEDS_MODE_PATH, serialNo, apiConfig.token); + } else { + return apiConfig.hostname; + } + } + + public static @Nullable Request generateRequest(HttpClient httpClient, @Nullable String url) { + return generateRequest(httpClient, url, HttpMethod.GET); + } + + public static @Nullable Request generateRequest(HttpClient httpClient, @Nullable String url, HttpMethod method) { + if (url == null) { + return null; + } + + Request request = httpClient.newRequest(url); + request.timeout(REQUEST_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + request.method(method); + return request; + } +} diff --git a/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/communication/RemoteAPIController.java b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/communication/RemoteAPIController.java new file mode 100644 index 00000000000..d4d8a6ba4c6 --- /dev/null +++ b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/communication/RemoteAPIController.java @@ -0,0 +1,146 @@ +/** + * Copyright (c) 2010-2024 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.airgradient.internal.communication; + +import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.CONTENTTYPE_JSON; +import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.CONTENTTYPE_OPENMETRICS; +import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.CONTENTTYPE_TEXT; +import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.REQUEST_TIMEOUT; + +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +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.eclipse.jetty.http.HttpMethod; +import org.eclipse.jetty.http.HttpStatus; +import org.openhab.binding.airgradient.internal.config.AirGradientAPIConfiguration; +import org.openhab.binding.airgradient.internal.model.LedMode; +import org.openhab.binding.airgradient.internal.model.Measure; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; + +/** + * Helper for doing rest calls to the AirGradient API. + * + * @author Jørgen Austvik - Initial contribution + */ +@NonNullByDefault +public class RemoteAPIController { + + private final Logger logger = LoggerFactory.getLogger(RemoteAPIController.class); + + private final HttpClient httpClient; + private final Gson gson; + private final AirGradientAPIConfiguration apiConfig; + + public RemoteAPIController(HttpClient httpClient, Gson gson, AirGradientAPIConfiguration apiConfig) { + this.httpClient = httpClient; + this.gson = gson; + this.apiConfig = apiConfig; + } + + /** + * Return list of measures from AirGradient API. + * + * @return list of measures + * @throws AirGradientCommunicationException if unable to communicate with sensor or API. + */ + public List getMeasures() throws AirGradientCommunicationException { + ContentResponse response = sendRequest( + RESTHelper.generateRequest(httpClient, RESTHelper.generateMeasuresUrl(apiConfig))); + if (response != null) { + String contentType = response.getMediaType(); + logger.debug("Got measurements with status {}: {} ({})", response.getStatus(), + response.getContentAsString(), contentType); + + if (HttpStatus.isSuccess(response.getStatus())) { + String stringResponse = response.getContentAsString().trim(); + + if (null != contentType) { + switch (contentType) { + case CONTENTTYPE_JSON: + return JsonParserHelper.parseJson(gson, stringResponse); + case CONTENTTYPE_TEXT: + return PrometheusParserHelper.parsePrometheus(stringResponse); + case CONTENTTYPE_OPENMETRICS: + return PrometheusParserHelper.parsePrometheus(stringResponse); + default: + logger.debug("Unhandled content type returned: {}", contentType); + } + } + } + } + + return Collections.emptyList(); + } + + public void setLedMode(String serialNo, String mode) throws AirGradientCommunicationException { + Request request = httpClient.newRequest(RESTHelper.generateGetLedsModeUrl(apiConfig, serialNo)); + request.timeout(REQUEST_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + request.method(HttpMethod.PUT); + request.header(HttpHeader.CONTENT_TYPE, CONTENTTYPE_JSON); + LedMode ledMode = new LedMode(); + ledMode.mode = mode; + String modeJson = gson.toJson(ledMode); + logger.debug("Setting LEDS mode for {}: {}", serialNo, modeJson); + request.content(new StringContentProvider(CONTENTTYPE_JSON, modeJson, StandardCharsets.UTF_8)); + sendRequest(request); + } + + public void calibrateCo2(String serialNo) throws AirGradientCommunicationException { + logger.debug("Triggering CO2 calibration for {}", serialNo); + sendRequest(RESTHelper.generateRequest(httpClient, RESTHelper.generateCalibrationCo2Url(apiConfig, serialNo), + HttpMethod.POST)); + } + + private @Nullable ContentResponse sendRequest(@Nullable final Request request) + throws AirGradientCommunicationException { + if (request == null) { + throw new AirGradientCommunicationException("Unable to generate request"); + } + + @Nullable + ContentResponse response = null; + try { + response = request.send(); + if (response != null) { + logger.debug("Response from {}: {}", request.getURI(), response.getStatus()); + if (!HttpStatus.isSuccess(response.getStatus())) { + throw new AirGradientCommunicationException("Returned status code: " + response.getStatus()); + } + } else { + throw new AirGradientCommunicationException("No response"); + } + } catch (InterruptedException | ExecutionException | TimeoutException e) { + String message = e.getMessage(); + if (message == null) { + message = "Communication error"; + } + throw new AirGradientCommunicationException(message, e); + } + + return response; + } +} diff --git a/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/config/AirGradientAPIConfiguration.java b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/config/AirGradientAPIConfiguration.java new file mode 100644 index 00000000000..62cb41407c9 --- /dev/null +++ b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/config/AirGradientAPIConfiguration.java @@ -0,0 +1,58 @@ +/** + * Copyright (c) 2010-2024 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.airgradient.internal.config; + +import java.net.URI; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link AirGradientAPIConfiguration} class contains fields mapping thing configuration parameters. + * + * @author Jørgen Austvik - Initial contribution + */ +@NonNullByDefault +public class AirGradientAPIConfiguration { + + public String hostname = ""; + public String token = ""; + public int refreshInterval = 600; + + public boolean isValid() { + // hostname must be entered and be a URI + if ("".equals(hostname)) { + return false; + } + + try { + URI.create(hostname); + } catch (IllegalArgumentException iae) { + return false; + } + + // token is optional + + // refresh interval is positive integer + return (refreshInterval > 0); + } + + /** + * Returns true if this is a URL against the cloud. + * + * @return true if this is a URL against the cloud API + */ + public boolean hasCloudUrl() { + URI url = URI.create(hostname); + return url.getPath().equals("/"); + } +} diff --git a/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/config/AirGradientLocationConfiguration.java b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/config/AirGradientLocationConfiguration.java new file mode 100644 index 00000000000..a7d5cabecf3 --- /dev/null +++ b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/config/AirGradientLocationConfiguration.java @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2010-2024 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.airgradient.internal.config; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link AirGradientLocationConfiguration} class contains fields mapping thing configuration parameters. + * + * @author Jørgen Austvik - Initial contribution + */ +@NonNullByDefault +public class AirGradientLocationConfiguration { + + public String location = ""; +} diff --git a/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/discovery/AirGradientLocationDiscoveryService.java b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/discovery/AirGradientLocationDiscoveryService.java new file mode 100644 index 00000000000..614eeb50e41 --- /dev/null +++ b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/discovery/AirGradientLocationDiscoveryService.java @@ -0,0 +1,154 @@ +/** + * Copyright (c) 2010-2024 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.airgradient.internal.discovery; + +import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.BACKGROUND_DISCOVERY; +import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.CONFIG_LOCATION; +import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.PROPERTY_NAME; +import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.SEARCH_TIME; +import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.THING_TYPE_LOCATION; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.airgradient.internal.communication.AirGradientCommunicationException; +import org.openhab.binding.airgradient.internal.handler.AirGradientAPIHandler; +import org.openhab.binding.airgradient.internal.handler.PollEventListener; +import org.openhab.binding.airgradient.internal.model.Measure; +import org.openhab.core.config.discovery.AbstractDiscoveryService; +import org.openhab.core.config.discovery.DiscoveryResult; +import org.openhab.core.config.discovery.DiscoveryResultBuilder; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingUID; +import org.openhab.core.thing.binding.BridgeHandler; +import org.openhab.core.thing.binding.ThingHandler; +import org.openhab.core.thing.binding.ThingHandlerService; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.ServiceScope; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link AirGradientLocationDiscoveryService} is responsible for discovering new locations + * that are not bound to any items. + * + * @author Jørgen Austvik - Initial contribution + */ +@Component(scope = ServiceScope.PROTOTYPE, service = AirGradientLocationDiscoveryService.class) +@NonNullByDefault +public class AirGradientLocationDiscoveryService extends AbstractDiscoveryService + implements ThingHandlerService, PollEventListener { + + private final Logger logger = LoggerFactory.getLogger(AirGradientLocationDiscoveryService.class); + + private @NonNullByDefault({}) AirGradientAPIHandler apiHandler; + + public AirGradientLocationDiscoveryService() { + super(Set.of(THING_TYPE_LOCATION), (int) SEARCH_TIME.getSeconds(), BACKGROUND_DISCOVERY); + } + + @Override + protected void startBackgroundDiscovery() { + logger.debug("Start AirGradient background discovery"); + apiHandler.addPollEventListener(this); + } + + @Override + protected void stopBackgroundDiscovery() { + logger.debug("Stopping AirGradient background discovery"); + apiHandler.removePollEventListener(this); + } + + @Override + public void pollEvent(List measures) { + BridgeHandler bridge = apiHandler.getThing().getHandler(); + if (bridge == null) { + logger.debug("Missing bridge, can't discover sensors for unknown bridge."); + return; + } + + ThingUID bridgeUid = bridge.getThing().getUID(); + + Set registeredLocationIds = new HashSet<>(apiHandler.getRegisteredLocationIds()); + for (Measure measure : measures) { + String id = measure.getLocationId(); + if (id.isEmpty()) { + // Local devices don't have location ID. + id = measure.getSerialNo(); + } + + String name = measure.getLocationName(); + if (name.isEmpty()) { + name = "Sensor_" + measure.getSerialNo(); + } + + if (!registeredLocationIds.contains(id)) { + Map properties = new HashMap<>(5); + properties.put(PROPERTY_NAME, name); + properties.put(Thing.PROPERTY_FIRMWARE_VERSION, measure.getFirmwareVersion()); + properties.put(Thing.PROPERTY_SERIAL_NUMBER, measure.getSerialNo()); + String model = measure.getModel(); + if (model != null) { + properties.put(Thing.PROPERTY_MODEL_ID, model); + } + properties.put(CONFIG_LOCATION, id); + + ThingUID thingUID = new ThingUID(THING_TYPE_LOCATION, bridgeUid, id); + + logger.debug("Adding location {} with id {} to bridge {} with location id {}", name, thingUID, + bridgeUid, measure.getLocationId()); + DiscoveryResult discoveryResult = DiscoveryResultBuilder.create(thingUID).withProperties(properties) + .withBridge(bridgeUid).withLabel(name).withRepresentationProperty(CONFIG_LOCATION).build(); + + thingDiscovered(discoveryResult); + } + } + } + + @Override + protected void startScan() { + try { + List measures = apiHandler.getApiController().getMeasures(); + pollEvent(measures); + } catch (AirGradientCommunicationException agce) { + logger.warn("Failed discovery due to communication exception: {}", agce.getMessage()); + } + } + + @Override + public void setThingHandler(ThingHandler handler) { + if (handler instanceof AirGradientAPIHandler airGradientAPIHandler) { + this.apiHandler = airGradientAPIHandler; + } + } + + @Override + public @Nullable ThingHandler getThingHandler() { + return apiHandler; + } + + @Override + public void activate() { + super.activate(null); + } + + @Override + public void deactivate() { + super.deactivate(); + } +} diff --git a/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/discovery/AirGradientMDNSDiscoveryParticipant.java b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/discovery/AirGradientMDNSDiscoveryParticipant.java new file mode 100644 index 00000000000..f5acc980f86 --- /dev/null +++ b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/discovery/AirGradientMDNSDiscoveryParticipant.java @@ -0,0 +1,122 @@ +/** + * Copyright (c) 2010-2024 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.airgradient.internal.discovery; + +import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.CONFIG_API_HOST_NAME; +import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.CONFIG_API_REFRESH_INTERVAL; +import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.CONFIG_API_TOKEN; +import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.CURRENT_MEASURES_LOCAL_PATH; +import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.DEFAULT_POLL_INTERVAL_LOCAL; +import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.THING_TYPE_LOCAL; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import javax.jmdns.ServiceInfo; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.config.discovery.DiscoveryResult; +import org.openhab.core.config.discovery.DiscoveryResultBuilder; +import org.openhab.core.config.discovery.mdns.MDNSDiscoveryParticipant; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingRegistry; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.ThingUID; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link AirGradientMDNSDiscoveryParticipant} is responsible for discovering new and removed AirGradient sensors. + * It uses the + * central {@link org.openhab.core.config.discovery.mdns.internal.MDNSDiscoveryService}. + * + * @author Jørgen Austvik - Initial contribution + */ +@Component(configurationPid = "discovery.airgradient") +@NonNullByDefault +public class AirGradientMDNSDiscoveryParticipant implements MDNSDiscoveryParticipant { + + private static final String SERVICE_TYPE = "_airgradient._tcp.local."; + private static final String MDNS_PROPERTY_SERIALNO = "serialno"; + private static final String MDNS_PROPERTY_MODEL = "model"; + + private final Logger logger = LoggerFactory.getLogger(AirGradientMDNSDiscoveryParticipant.class); + protected final ThingRegistry thingRegistry; + + @Activate + public AirGradientMDNSDiscoveryParticipant(final @Reference ThingRegistry thingRegistry) { + this.thingRegistry = thingRegistry; + } + + @Override + public Set getSupportedThingTypeUIDs() { + return Set.of(THING_TYPE_LOCAL); + } + + @Override + public String getServiceType() { + return SERVICE_TYPE; + } + + @Override + public @Nullable DiscoveryResult createResult(ServiceInfo si) { + logger.debug("Discovered {} at {}: {}", si.getQualifiedName(), si.getURLs(), si.getNiceTextString()); + + String urls[] = si.getURLs(); + if (urls == null || urls.length < 1) { + logger.debug("Not able to find URLs for {}, not autodetecting", si.getQualifiedName()); + return null; + } + + String hostName = urls[0] + CURRENT_MEASURES_LOCAL_PATH; + String model = si.getPropertyString(MDNS_PROPERTY_MODEL); + + Map properties = new HashMap<>(4); + properties.put(CONFIG_API_TOKEN, ""); + properties.put(CONFIG_API_HOST_NAME, hostName); + properties.put(CONFIG_API_REFRESH_INTERVAL, DEFAULT_POLL_INTERVAL_LOCAL.getSeconds()); + properties.put(Thing.PROPERTY_MODEL_ID, model); + + ThingUID thingUID = getThingUID(si); + if (thingUID == null) { + logger.debug("Failed creating thing as we couldn't create a UID for it (missing serialno)"); + return null; + } + + logger.debug("Autodiscovered API {} with id {} with host name {}. It is a {}", si.getName(), thingUID, hostName, + model); + + DiscoveryResult discoveryResult = DiscoveryResultBuilder.create(thingUID).withProperties(properties) + .withLabel(si.getName()).withRepresentationProperty(CONFIG_API_HOST_NAME).build(); + + return discoveryResult; + } + + @Override + public @Nullable ThingUID getThingUID(ServiceInfo si) { + logger.debug("Getting thing ID for: App: {} Host: {} Name: {} Port: {} Serial: {}", si.getApplication(), + si.getHostAddresses(), si.getName(), si.getPort(), si.getPropertyString("serialno")); + + String serialNo = si.getPropertyString(MDNS_PROPERTY_SERIALNO); + if (serialNo == null) { + return null; + } + + return new ThingUID(THING_TYPE_LOCAL, serialNo); + } +} diff --git a/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/AirGradientAPIHandler.java b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/AirGradientAPIHandler.java new file mode 100644 index 00000000000..35b22158d97 --- /dev/null +++ b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/AirGradientAPIHandler.java @@ -0,0 +1,199 @@ +/** + * Copyright (c) 2010-2024 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.airgradient.internal.handler; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.openhab.binding.airgradient.internal.communication.AirGradientCommunicationException; +import org.openhab.binding.airgradient.internal.communication.RemoteAPIController; +import org.openhab.binding.airgradient.internal.config.AirGradientAPIConfiguration; +import org.openhab.binding.airgradient.internal.discovery.AirGradientLocationDiscoveryService; +import org.openhab.binding.airgradient.internal.model.Measure; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.binding.BaseBridgeHandler; +import org.openhab.core.thing.binding.ThingHandlerService; +import org.openhab.core.types.Command; +import org.openhab.core.types.RefreshType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; + +/** + * The {@link AirGradientAPIHandler} is responsible for handling commands, which are + * sent to one of the channels. + * + * @author Jørgen Austvik - Initial contribution + */ +@NonNullByDefault +public class AirGradientAPIHandler extends BaseBridgeHandler { + + private final Logger logger = LoggerFactory.getLogger(AirGradientAPIHandler.class); + + private @Nullable ScheduledFuture pollingJob; + private final HttpClient httpClient; + private final Gson gson; + private final Set pollListeners = new HashSet<>(1); + + private @NonNullByDefault({}) RemoteAPIController apiController = null; + private @NonNullByDefault({}) AirGradientAPIConfiguration apiConfig = null; + + public AirGradientAPIHandler(Bridge bridge, HttpClient httpClient) { + super(bridge); + this.httpClient = httpClient; + this.gson = new Gson(); + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + if (command instanceof RefreshType) { + pollingCode(); + } else { + // This is read only + logger.warn("Received command {} for channel {}, but the API is read only", command.toString(), + channelUID.getId()); + } + } + + @Override + public void initialize() { + apiConfig = getConfigAs(AirGradientAPIConfiguration.class); + if (!apiConfig.isValid()) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "Need to set hostname to a valid URL. Refresh interval needs to be a positive integer."); + return; + } + + apiController = new RemoteAPIController(httpClient, gson, apiConfig); + + // set the thing status to UNKNOWN temporarily and let the background task decide for the real status. + // the framework is then able to reuse the resources from the thing handler initialization. + // we set this upfront to reliably check status updates in unit tests. + updateStatus(ThingStatus.UNKNOWN); + + pollingJob = scheduler.scheduleWithFixedDelay(this::pollingCode, 0, apiConfig.refreshInterval, + TimeUnit.SECONDS); + } + + private static String getMeasureId(Measure measure) { + String id = measure.getLocationId(); + if (id.isEmpty()) { + // Local devices don't have location ID. + id = measure.getSerialNo(); + } + + return id; + } + + protected void pollingCode() { + try { + List measures = apiController.getMeasures(); + updateStatus(ThingStatus.ONLINE); + triggerPollEvent(measures); + + Map measureMap = measures.stream() + .collect(Collectors.toMap((m) -> getMeasureId(m), (m) -> m)); + + for (Thing t : getThing().getThings()) { + if (t.getHandler() instanceof AirGradientLocationHandler handler) { + String locationId = handler.getLocationId(); + @Nullable + Measure measure = measureMap.get(locationId); + if (measure != null) { + handler.setMeasurment(measure); + } else { + logger.debug("Could not find measures for location {}", locationId); + } + } + } + } catch (AirGradientCommunicationException agce) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, agce.getMessage()); + } + } + + /** + * Return location ids we already have things for. + * + * @return location ids we already have things for. + */ + public List getRegisteredLocationIds() { + List things = getThing().getThings(); + List results = new ArrayList<>(things.size()); + for (Thing t : things) { + if (t.getHandler() instanceof AirGradientLocationHandler handler) { + results.add(handler.getLocationId()); + } + } + + return results; + } + + @Override + public void dispose() { + ScheduledFuture pollingJob = this.pollingJob; + if (pollingJob != null) { + pollingJob.cancel(true); + this.pollingJob = null; + } + } + + protected void setConfiguration(AirGradientAPIConfiguration config) { + this.apiConfig = config; + } + + protected void setApiController(RemoteAPIController apiController) { + this.apiController = apiController; + } + + public RemoteAPIController getApiController() { + return apiController; + } + + // Event listening + + public void addPollEventListener(PollEventListener listener) { + pollListeners.add(listener); + } + + public void removePollEventListener(PollEventListener listener) { + pollListeners.remove(listener); + } + + public void triggerPollEvent(List measures) { + for (PollEventListener listener : pollListeners) { + listener.pollEvent(measures); + } + } + + // Discovery + + @Override + public Collection> getServices() { + return Set.of(AirGradientLocationDiscoveryService.class); + } +} diff --git a/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/AirGradientLocalHandler.java b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/AirGradientLocalHandler.java new file mode 100644 index 00000000000..03878c26b71 --- /dev/null +++ b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/AirGradientLocalHandler.java @@ -0,0 +1,183 @@ +/** + * Copyright (c) 2010-2024 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.airgradient.internal.handler; + +import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.CHANNEL_CALIBRATION; +import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.CHANNEL_LEDS_MODE; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.openhab.binding.airgradient.internal.communication.AirGradientCommunicationException; +import org.openhab.binding.airgradient.internal.communication.RemoteAPIController; +import org.openhab.binding.airgradient.internal.config.AirGradientAPIConfiguration; +import org.openhab.binding.airgradient.internal.model.Measure; +import org.openhab.core.library.types.StringType; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.binding.BaseThingHandler; +import org.openhab.core.types.Command; +import org.openhab.core.types.RefreshType; +import org.openhab.core.types.State; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; + +/** + * The {@link AirGradientAPIHandler} is responsible for handling commands, which are + * sent to one of the channels. + * + * @author Jørgen Austvik - Initial contribution + */ +@NonNullByDefault +public class AirGradientLocalHandler extends BaseThingHandler { + + private final Logger logger = LoggerFactory.getLogger(AirGradientLocalHandler.class); + + private @Nullable ScheduledFuture pollingJob; + private final HttpClient httpClient; + private final Gson gson; + + private @NonNullByDefault({}) RemoteAPIController apiController = null; + private @NonNullByDefault({}) AirGradientAPIConfiguration apiConfig = null; + + public AirGradientLocalHandler(Thing thing, HttpClient httpClient) { + super(thing); + this.httpClient = httpClient; + this.gson = new Gson(); + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + logger.debug("Channel {}: {}", channelUID, command.toFullString()); + if (command instanceof RefreshType) { + pollingCode(); + } else if (CHANNEL_LEDS_MODE.equals(channelUID.getId())) { + if (command instanceof StringType stringCommand) { + setLedModeOnDevice(stringCommand.toFullString()); + } else { + logger.warn("Received command {} for channel {}, but it needs a string command", command.toString(), + channelUID.getId()); + } + } else if (CHANNEL_CALIBRATION.equals(channelUID.getId())) { + if (command instanceof StringType stringCommand) { + if ("co2".equals(stringCommand.toFullString())) { + calibrateCo2OnDevice(); + } else { + logger.warn( + "Received unknown command {} for calibration on channel {}, which we don't know how to handle", + command.toString(), channelUID.getId()); + } + } + } else { + // This is read only + logger.warn("Received command {} for channel {}, which we don't know how to handle", command.toString(), + channelUID.getId()); + } + } + + @Override + public void initialize() { + apiConfig = getConfigAs(AirGradientAPIConfiguration.class); + if (!apiConfig.isValid()) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "Need to set hostname to a valid URL. Refresh interval needs to be a positive integer."); + return; + } + + apiController = new RemoteAPIController(httpClient, gson, apiConfig); + + // set the thing status to UNKNOWN temporarily and let the background task decide for the real status. + // the framework is then able to reuse the resources from the thing handler initialization. + // we set this upfront to reliably check status updates in unit tests. + updateStatus(ThingStatus.UNKNOWN); + + pollingJob = scheduler.scheduleWithFixedDelay(this::pollingCode, 0, apiConfig.refreshInterval, + TimeUnit.SECONDS); + } + + protected void pollingCode() { + try { + List measures = apiController.getMeasures(); + updateStatus(ThingStatus.ONLINE); + + if (measures.size() != 1) { + logger.warn("Expecting single set of measures for local device, but got {} measures", measures.size()); + return; + } + + updateProperties(MeasureHelper.createProperties(measures.get(0))); + Map states = MeasureHelper.createStates(measures.get(0)); + for (Map.Entry entry : states.entrySet()) { + if (isLinked(entry.getKey())) { + updateState(entry.getKey(), entry.getValue()); + } + } + } catch (AirGradientCommunicationException agce) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, agce.getMessage()); + } + } + + private void setLedModeOnDevice(String mode) { + try { + apiController.setLedMode(getSerialNo(), mode); + updateStatus(ThingStatus.ONLINE); + } catch (AirGradientCommunicationException agce) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, agce.getMessage()); + } + } + + private void calibrateCo2OnDevice() { + try { + apiController.calibrateCo2(getSerialNo()); + updateStatus(ThingStatus.ONLINE); + } catch (AirGradientCommunicationException agce) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, agce.getMessage()); + } + } + + /** + * Returns the serial number of this sensor. + * + * @return serial number of this sensor. + */ + public String getSerialNo() { + String serialNo = thing.getProperties().get(Thing.PROPERTY_SERIAL_NUMBER); + if (serialNo == null) { + serialNo = ""; + } + + return serialNo; + } + + @Override + public void dispose() { + ScheduledFuture pollingJob = this.pollingJob; + if (pollingJob != null) { + pollingJob.cancel(true); + this.pollingJob = null; + } + } + + protected void setConfiguration(AirGradientAPIConfiguration config) { + this.apiConfig = config; + } +} diff --git a/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/AirGradientLocationHandler.java b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/AirGradientLocationHandler.java new file mode 100644 index 00000000000..22734bae974 --- /dev/null +++ b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/AirGradientLocationHandler.java @@ -0,0 +1,160 @@ +/** + * Copyright (c) 2010-2024 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.airgradient.internal.handler; + +import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.*; + +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.airgradient.internal.communication.AirGradientCommunicationException; +import org.openhab.binding.airgradient.internal.config.AirGradientLocationConfiguration; +import org.openhab.binding.airgradient.internal.model.Measure; +import org.openhab.core.library.types.StringType; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.binding.BaseThingHandler; +import org.openhab.core.types.Command; +import org.openhab.core.types.RefreshType; +import org.openhab.core.types.State; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link AirGradientAPIHandler} is responsible for handling commands, which are + * sent to one of the channels. + * + * @author Jørgen Austvik - Initial contribution + */ +@NonNullByDefault +public class AirGradientLocationHandler extends BaseThingHandler { + + private final Logger logger = LoggerFactory.getLogger(AirGradientLocationHandler.class); + + private @NonNullByDefault({}) AirGradientLocationConfiguration locationConfig = null; + + public AirGradientLocationHandler(Thing thing) { + super(thing); + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + logger.debug("Channel {}: {}", channelUID, command.toFullString()); + if (command instanceof RefreshType) { + Bridge bridge = getBridge(); + if (bridge != null) { + if (bridge.getHandler() instanceof AirGradientAPIHandler handler) { + handler.pollingCode(); + } + } + } else if (CHANNEL_LEDS_MODE.equals(channelUID.getId())) { + if (command instanceof StringType stringCommand) { + setLedModeOnDevice(stringCommand.toFullString()); + } else { + logger.warn("Received command {} for channel {}, but it needs a string command", command.toString(), + channelUID.getId()); + } + } else if (CHANNEL_CALIBRATION.equals(channelUID.getId())) { + if (command instanceof StringType stringCommand) { + if ("co2".equals(stringCommand.toFullString())) { + calibrateCo2OnDevice(); + } else { + logger.warn( + "Received unknown command {} for calibration on channel {}, which we don't know how to handle", + command.toString(), channelUID.getId()); + } + } + } else { + // This is read only + logger.warn("Received command {} for channel {}, which we don't know how to handle", command.toString(), + channelUID.getId()); + } + } + + private void setLedModeOnDevice(String mode) { + Bridge bridge = getBridge(); + if (bridge != null) { + if (bridge.getHandler() instanceof AirGradientAPIHandler handler) { + try { + handler.getApiController().setLedMode(getSerialNo(), mode); + updateStatus(ThingStatus.ONLINE); + } catch (AirGradientCommunicationException agce) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, agce.getMessage()); + } + } + } + } + + private void calibrateCo2OnDevice() { + Bridge bridge = getBridge(); + if (bridge != null) { + if (bridge.getHandler() instanceof AirGradientAPIHandler handler) { + try { + handler.getApiController().calibrateCo2(getSerialNo()); + updateStatus(ThingStatus.ONLINE); + } catch (AirGradientCommunicationException agce) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, agce.getMessage()); + } + } + } + } + + @Override + public void initialize() { + // set the thing status to UNKNOWN temporarily and let the background task decide for the real status. + // the framework is then able to reuse the resources from the thing handler initialization. + // we set this upfront to reliably check status updates in unit tests. + updateStatus(ThingStatus.UNKNOWN); + locationConfig = getConfigAs(AirGradientLocationConfiguration.class); + + Bridge controller = getBridge(); + if (controller == null) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED); + } else if (ThingStatus.OFFLINE.equals(controller.getStatus())) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE); + } else { + updateStatus(ThingStatus.ONLINE); + } + } + + public String getLocationId() { + return locationConfig.location; + } + + public void setMeasurment(Measure measure) { + updateProperties(MeasureHelper.createProperties(measure)); + Map states = MeasureHelper.createStates(measure); + for (Map.Entry entry : states.entrySet()) { + if (isLinked(entry.getKey())) { + updateState(entry.getKey(), entry.getValue()); + } + } + } + + /** + * Returns the serial number of this sensor. + * + * @return serial number of this sensor. + */ + private String getSerialNo() { + String serialNo = thing.getProperties().get(Thing.PROPERTY_SERIAL_NUMBER); + if (serialNo == null) { + serialNo = ""; + } + + return serialNo; + } +} diff --git a/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/MeasureHelper.java b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/MeasureHelper.java new file mode 100644 index 00000000000..41e724e3937 --- /dev/null +++ b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/MeasureHelper.java @@ -0,0 +1,100 @@ +/** + * Copyright (c) 2010-2024 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.airgradient.internal.handler; + +import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.*; + +import java.util.HashMap; +import java.util.Map; + +import javax.measure.Unit; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.airgradient.internal.model.Measure; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.library.unit.SIUnits; +import org.openhab.core.library.unit.Units; +import org.openhab.core.thing.Thing; +import org.openhab.core.types.State; +import org.openhab.core.types.UnDefType; + +/** + * Helper class to reduce code duplication across things. + * + * @author Jørgen Austvik - Initial contribution + */ +@NonNullByDefault +public class MeasureHelper { + + public static Map createProperties(Measure measure) { + Map properties = new HashMap<>(4); + String firmwareVersion = measure.firmwareVersion; + if (firmwareVersion != null) { + properties.put(Thing.PROPERTY_FIRMWARE_VERSION, firmwareVersion); + } + + String locationName = measure.locationName; + if (locationName != null) { + properties.put(PROPERTY_NAME, locationName); + } + + String serialNo = measure.serialno; + if (serialNo != null) { + properties.put(Thing.PROPERTY_SERIAL_NUMBER, serialNo); + } + + String model = measure.getModel(); + if (model != null) { + properties.put(Thing.PROPERTY_MODEL_ID, model); + } + + return properties; + } + + public static Map createStates(Measure measure) { + Map states = new HashMap<>(11); + + states.put(CHANNEL_ATMP, toQuantityType(measure.atmp, SIUnits.CELSIUS)); + states.put(CHANNEL_PM_003_COUNT, toQuantityType(measure.pm003Count, Units.ONE)); + states.put(CHANNEL_PM_01, toQuantityType(measure.pm01, Units.MICROGRAM_PER_CUBICMETRE)); + states.put(CHANNEL_PM_02, toQuantityType(measure.pm02, Units.MICROGRAM_PER_CUBICMETRE)); + states.put(CHANNEL_PM_10, toQuantityType(measure.pm10, Units.MICROGRAM_PER_CUBICMETRE)); + states.put(CHANNEL_RHUM, toQuantityType(measure.rhum, Units.PERCENT)); + states.put(CHANNEL_UPLOADS_SINCE_BOOT, toQuantityType(measure.boot, Units.ONE)); + + Double rco2 = measure.rco2; + if (rco2 != null) { + states.put(CHANNEL_RCO2, toQuantityType(rco2.longValue(), Units.PARTS_PER_MILLION)); + } + + Double tvoc = measure.tvoc; + if (tvoc != null) { + states.put(CHANNEL_TVOC, toQuantityType(tvoc.longValue(), Units.PARTS_PER_BILLION)); + } + + states.put(CHANNEL_WIFI, toQuantityType(measure.wifi, Units.DECIBEL_MILLIWATTS)); + states.put(CHANNEL_LEDS_MODE, toStringType(measure.ledMode)); + + return states; + } + + private static State toQuantityType(@Nullable Number value, Unit unit) { + return value == null ? UnDefType.NULL : new QuantityType<>(value, unit); + } + + private static State toStringType(@Nullable String value) { + return value == null ? UnDefType.NULL : StringType.valueOf(value); + } +} diff --git a/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/PollEventListener.java b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/PollEventListener.java new file mode 100644 index 00000000000..db6a0134c0f --- /dev/null +++ b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/PollEventListener.java @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2010-2024 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.airgradient.internal.handler; + +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.airgradient.internal.model.Measure; + +/** + * Interface for listening to polls. + * + * @author Jørgen Austvik - Initial contribution + */ +@NonNullByDefault +public interface PollEventListener { + + /** + * Called when a poll has happened. + * + * @param measures Measures that has been read in a successful poll + */ + public void pollEvent(List measures); +} diff --git a/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/model/LedMode.java b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/model/LedMode.java new file mode 100644 index 00000000000..831e74b2303 --- /dev/null +++ b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/model/LedMode.java @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2010-2024 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.airgradient.internal.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * Data model class for a single led mode from AirGradients API. + * + * @author Jørgen Austvik - Initial contribution + */ +@NonNullByDefault +public class LedMode { + @Nullable + public String mode; +} diff --git a/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/model/Measure.java b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/model/Measure.java new file mode 100644 index 00000000000..726ca276c80 --- /dev/null +++ b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/model/Measure.java @@ -0,0 +1,165 @@ +/** + * Copyright (c) 2010-2024 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.airgradient.internal.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * Data model class for a single measurement from AirGradients API. + * + * @author Jørgen Austvik - Initial contribution + */ +@NonNullByDefault +public class Measure { + + /** + * Returns a location id that is guaranteed to not be null. + * + * @return A non null location id. + */ + public String getLocationId() { + String loc = locationId; + if (loc != null) { + return loc; + } + + return ""; + } + + /** + * Returns a location name that is guaranteed to not be null. + * + * @return A non null location name. + */ + public String getLocationName() { + String name = locationName; + return (name != null) ? name : ""; + } + + /** + * Returns a serial number that is guaranteed to not be null. + * + * @return A non null serial number. + */ + public String getSerialNo() { + String serial = serialno; + if (serial != null) { + return serial; + } + + return ""; + } + + /** + * Returns a firmware version that is guaranteed to not be null. + * + * @return A non null firmware version. + */ + public String getFirmwareVersion() { + String fw = firmwareVersion; + if (fw != null) { + return fw; + } + + return ""; + } + + public @Nullable String getModel() { + // model from cloud API + String m = model; + if (m != null) { + return m; + } + + // model from local API + m = fwMode; + if (m != null) { + return m; + } + + return null; + } + + @Nullable + public String locationId; + + @Nullable + public String locationName; + + @Nullable + public String serialno; + + @Nullable + public Double pm01; // The raw PM 1 value in ug + + @Nullable + public Double pm02; // The raw PM 2.5 value in ug + + @Nullable + public Double pm10; // The raw PM 10 value in ug + + @Nullable + public Double pm003Count; // The number of particles with a diameter beyond 0.3 microns in 1 deciliter of air + + @Nullable + public Double atmp; // The ambient temperature in celsius + + @Nullable + public Double rhum; // The relative humidity in percent + + @Nullable + public Double rco2; // The CO2 value in ppm + + @Nullable + public Double tvoc; // The TVOC value in ppb, provided in case that the sensor delivers an absolute value + + @Nullable + public Double tvocIndex; // The value of the TVOC index, sensor model dependent + + @Nullable + public Double noxIndex; // The value of the NOx index, sensor model dependent + + @Nullable + public Double wifi; // The wifi signal strength in dBm + + @Nullable + public Integer datapoints; // The number of datapoints, present only for aggregated data + + @Nullable + public String timestamp; // Timestamp of the measures in ISO 8601 format with UTC offset, e.g. 2022-03-28T12:07:40Z + + @Nullable + public String firmwareVersion; // The firmware version running on the device, e.g. "9.2.6", not present for averages + + @Nullable + public String ledMode; // co2, pm, off, default + + @Nullable + public String ledCo2Threshold1; + + @Nullable + public String ledCo2Threshold2; + + @Nullable + public String ledCo2ThresholdEnd; + + @Nullable + public Long boot; // Number of times sensor has uploaded data since last reboot + + @Nullable + public String fwMode; // Model of sensor from local API + + @Nullable + public String model; // Model of sensor from cloud API +} diff --git a/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/prometheus/PrometheusMetric.java b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/prometheus/PrometheusMetric.java new file mode 100644 index 00000000000..87bb6932e20 --- /dev/null +++ b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/prometheus/PrometheusMetric.java @@ -0,0 +1,124 @@ +/** + * Copyright (c) 2010-2024 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.airgradient.internal.prometheus; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * Single metric from Prometheus. + * + * Based on specification in + * https://github.com/Showmax/prometheus-docs/blob/master/content/docs/instrumenting/exposition_formats.md + * + * @author Jørgen Austvik - Initial contribution + */ +@NonNullByDefault +public class PrometheusMetric { + private final String metricName; + private final double value; + private final Instant timestamp; + private final Map labels; + + public PrometheusMetric(String metricName, double value, Instant timestamp, Map labels) { + this.metricName = metricName; + this.value = value; + this.timestamp = timestamp; + this.labels = labels; + } + + /** + * Parses a prometheus line. + * + * @param line The line to parse + * @return The information we are able to parse from the line + */ + public static @Nullable PrometheusMetric parse(String line) { + String trimmedLine = line.trim(); + + if (trimmedLine.isEmpty() || trimmedLine.startsWith("#")) { + return null; + } + + String[] parts = trimmedLine.split("[{}]"); + if (parts.length == 3) { + String[] valueParts = parts[2].trim().split("[\t ]+"); + return switch (valueParts.length) { + case 1 -> new PrometheusMetric(parts[0], Double.parseDouble(valueParts[0]), Instant.MIN, + parseLabels(parts[1])); + case 2 -> new PrometheusMetric(parts[0], Double.parseDouble(valueParts[0]), + Instant.ofEpochMilli(Long.parseLong(valueParts[1])), parseLabels(parts[1])); + default -> null; + }; + } else if (parts.length == 2) { + // no idea what this is + return null; + } else if (parts.length == 1) { + // no properties, parse on whitespace + parts = trimmedLine.split("[\t ]"); + return switch (parts.length) { + case 3 -> new PrometheusMetric(parts[0], Double.parseDouble(parts[1]), + Instant.ofEpochMilli(Long.parseLong(parts[2])), new HashMap<>()); + case 2 -> new PrometheusMetric(parts[0], Double.parseDouble(parts[1]), Instant.MIN, new HashMap<>()); + default -> null; // No idea what this is + }; + } + + return null; + } + + private static Map parseLabels(String labelPart) { + String[] labels = labelPart.split(","); + Map results = new HashMap<>(labels.length); + + for (String label : labels) { + String parts[] = label.split("="); + if (parts.length != 2) { + continue; + } + + String labelName = parts[0].trim(); + String labelValue = parts[1].trim(); + if (labelValue.startsWith("\"")) { + labelValue = labelValue.substring(1); + } + if (labelValue.endsWith("\"")) { + labelValue = labelValue.substring(0, labelValue.length() - 1); + } + + results.put(labelName, labelValue); + } + + return results; + } + + public String getMetricName() { + return metricName; + } + + public double getValue() { + return value; + } + + public Instant getTimeStamp() { + return timestamp; + } + + public Map getLabels() { + return labels; + } +} diff --git a/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/prometheus/PrometheusTextParser.java b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/prometheus/PrometheusTextParser.java new file mode 100644 index 00000000000..055f4163e55 --- /dev/null +++ b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/prometheus/PrometheusTextParser.java @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2010-2024 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.airgradient.internal.prometheus; + +import java.util.ArrayList; +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * Prometheus text format parser. + * + * Based on specification in + * https://github.com/Showmax/prometheus-docs/blob/master/content/docs/instrumenting/exposition_formats.md + * + * @author Jørgen Austvik - Initial contribution + */ +@NonNullByDefault +public class PrometheusTextParser { + + public static List parse(String text) { + String[] lines = text.split("\\r?\\n"); + List metrics = new ArrayList<>(lines.length); + for (String line : lines) { + @Nullable + PrometheusMetric metric = PrometheusMetric.parse(line); + if (metric != null) { + metrics.add(metric); + } + } + return metrics; + } +} diff --git a/bundles/org.openhab.binding.airgradient/src/main/resources/OH-INF/addon/addon.xml b/bundles/org.openhab.binding.airgradient/src/main/resources/OH-INF/addon/addon.xml new file mode 100644 index 00000000000..9def4fab3fa --- /dev/null +++ b/bundles/org.openhab.binding.airgradient/src/main/resources/OH-INF/addon/addon.xml @@ -0,0 +1,24 @@ + + + + binding + AirGradient Binding + This is the binding for AirGradient air quality sensors. + hybrid + + + + + mdns + + + mdnsServiceType + _airgradient._tcp.local. + + + + + + diff --git a/bundles/org.openhab.binding.airgradient/src/main/resources/OH-INF/i18n/airgradient.properties b/bundles/org.openhab.binding.airgradient/src/main/resources/OH-INF/i18n/airgradient.properties new file mode 100644 index 00000000000..3eff5f5280f --- /dev/null +++ b/bundles/org.openhab.binding.airgradient/src/main/resources/OH-INF/i18n/airgradient.properties @@ -0,0 +1,50 @@ +# add-on + +addon.airgradient.name = AirGradient Binding +addon.airgradient.description = This is the binding for AirGradient air quality sensors. + +# thing types + +thing-type.airgradient.airgradient-api.label = AirGradient API +thing-type.airgradient.airgradient-api.description = Connection to the AirGradient API +thing-type.airgradient.location.label = AirGradient Location +thing-type.airgradient.location.description = AirGradient Location is where measurements are made + +# thing types config + +thing-type.config.airgradient.airgradient-api.hostname.label = Hostname +thing-type.config.airgradient.airgradient-api.hostname.description = Hostname or IP address of the API +thing-type.config.airgradient.airgradient-api.refreshInterval.label = Refresh Interval +thing-type.config.airgradient.airgradient-api.refreshInterval.description = Interval the device is polled in sec. +thing-type.config.airgradient.airgradient-api.token.label = Token +thing-type.config.airgradient.airgradient-api.token.description = Token to access the device +thing-type.config.airgradient.location.location.label = Location ID +thing-type.config.airgradient.location.location.description = ID of the location + +# channel types + +channel-type.airgradient.calibration.label = Calibration +channel-type.airgradient.calibration.description = Calibrate Sensors +channel-type.airgradient.calibration.command.option.co2 = co2 +channel-type.airgradient.co2.label = CO2 +channel-type.airgradient.co2.description = CarbonDioxide +channel-type.airgradient.leds-mode.label = LEDs Mode +channel-type.airgradient.leds-mode.description = Mode for the LEDs +channel-type.airgradient.leds-mode.state.option.default = default +channel-type.airgradient.leds-mode.state.option.off = off +channel-type.airgradient.leds-mode.state.option.pm = pm +channel-type.airgradient.leds-mode.state.option.co2 = co2 +channel-type.airgradient.particle-count.label = Particle Count +channel-type.airgradient.particle-count.description = Count of particles in 1 decilitre of air +channel-type.airgradient.pm1.label = PM1 +channel-type.airgradient.pm1.description = Particulate Matter 1 (0.001mm) +channel-type.airgradient.pm10.label = PM10 +channel-type.airgradient.pm10.description = Particulate Matter 10 (0.01mm) +channel-type.airgradient.pm2.label = PM2 +channel-type.airgradient.pm2.description = Particulate Matter 2 (0.002mm) +channel-type.airgradient.tvoc.label = TVOC +channel-type.airgradient.tvoc.description = Total Volatile Organic Compounds +channel-type.airgradient.uploads-since-boot.label = Upload count +channel-type.airgradient.uploads-since-boot.description = Number of uploads since last reboot (boot) +channel-type.airgradient.wifi.label = RSSI +channel-type.airgradient.wifi.description = Received signal strength indicator diff --git a/bundles/org.openhab.binding.airgradient/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.airgradient/src/main/resources/OH-INF/thing/thing-types.xml new file mode 100644 index 00000000000..b4557920723 --- /dev/null +++ b/bundles/org.openhab.binding.airgradient/src/main/resources/OH-INF/thing/thing-types.xml @@ -0,0 +1,202 @@ + + + + + + + Connection to the AirGradient Cloud API + + token + + + password + + Token to access the device + + + network-address + + https://api.airgradient.com/ + Hostname or IP address of the API + true + + + + Interval the device is polled in sec. + 600 + true + + + + + + + + Direct network connection to a local AirGradient Sensor + + + + + + + + + + + + + + + + + + + + + + + serialNumber + + + + network-address + + http://192.168.1.1:80/measures/current + Hostname or IP address of the API + false + + + + Interval the device is polled in sec. + 10 + true + + + + + + + + + + + + AirGradient Location for data from the AirGradient Cloud API + + + + + + + + + + + + + + + + + + + + + + + location + + + + + ID of the location + + + + + + Number:Density + + Particulate Matter 1 (0.001mm) + + + + + Number:Density + + Particulate Matter 2 (0.002mm) + + + + + Number:Density + + Particulate Matter 10 (0.01mm) + + + + + Number:Dimensionless + + Count of particles in 1 decilitre of air + + + + + Number + + Received signal strength indicator + QualityOfService + + + + + Number:Dimensionless + + CarbonDioxide + + + + + Number:Dimensionless + + Total Volatile Organic Compounds + + + + + Number:Dimensionless + + Number of uploads since last reboot (boot) + + + + + String + + Mode for the LEDs + + + + + + + + + + + + String + + Calibrate Sensors + + + + + + + + diff --git a/bundles/org.openhab.binding.airgradient/src/test/java/org/openhab/binding/airgradient/internal/AirGradientHandlerFactoryTest.java b/bundles/org.openhab.binding.airgradient/src/test/java/org/openhab/binding/airgradient/internal/AirGradientHandlerFactoryTest.java new file mode 100644 index 00000000000..6d5c315b576 --- /dev/null +++ b/bundles/org.openhab.binding.airgradient/src/test/java/org/openhab/binding/airgradient/internal/AirGradientHandlerFactoryTest.java @@ -0,0 +1,74 @@ +/** + * Copyright (c) 2010-2024 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.airgradient.internal; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; +import static org.hamcrest.core.Is.is; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingTypeUID; + +/** + * @author Jørgen Austvik - Initial contribution + */ +@SuppressWarnings({ "null" }) +@NonNullByDefault +public class AirGradientHandlerFactoryTest { + + @Test + public void testSupportsThingTypes() { + HttpClientFactory httpClientFactoryMock = Mockito.mock(HttpClientFactory.class); + AirGradientHandlerFactory sut = new AirGradientHandlerFactory(httpClientFactoryMock); + + assertThat(sut.supportsThingType(AirGradientBindingConstants.THING_TYPE_API), is(true)); + assertThat(sut.supportsThingType(AirGradientBindingConstants.THING_TYPE_LOCATION), is(true)); + assertThat(sut.supportsThingType(new ThingTypeUID("unknown", "thingtype")), is(false)); + } + + @Test + public void testCanCreateAPIHandler() { + HttpClientFactory httpClientFactoryMock = Mockito.mock(HttpClientFactory.class); + AirGradientHandlerFactory sut = new AirGradientHandlerFactory(httpClientFactoryMock); + Bridge bridgeMock = Mockito.mock(Bridge.class); + Mockito.when(bridgeMock.getThingTypeUID()).thenReturn(AirGradientBindingConstants.THING_TYPE_API); + + assertThat(sut.createHandler(bridgeMock), is(notNullValue())); + } + + @Test + public void testCanCreateLocationHandler() { + HttpClientFactory httpClientFactoryMock = Mockito.mock(HttpClientFactory.class); + AirGradientHandlerFactory sut = new AirGradientHandlerFactory(httpClientFactoryMock); + Thing thingMock = Mockito.mock(Thing.class); + Mockito.when(thingMock.getThingTypeUID()).thenReturn(AirGradientBindingConstants.THING_TYPE_LOCATION); + + assertThat(sut.createHandler(thingMock), is(notNullValue())); + } + + @Test + public void testCanCreateUnknownHandler() { + HttpClientFactory httpClientFactoryMock = Mockito.mock(HttpClientFactory.class); + AirGradientHandlerFactory sut = new AirGradientHandlerFactory(httpClientFactoryMock); + Thing thingMock = Mockito.mock(Thing.class); + Mockito.when(thingMock.getThingTypeUID()).thenReturn(new ThingTypeUID("unknown", "thingtype")); + + assertThat(sut.createHandler(thingMock), is(nullValue())); + } +} diff --git a/bundles/org.openhab.binding.airgradient/src/test/java/org/openhab/binding/airgradient/internal/handler/AirGradientAPIHandlerTest.java b/bundles/org.openhab.binding.airgradient/src/test/java/org/openhab/binding/airgradient/internal/handler/AirGradientAPIHandlerTest.java new file mode 100644 index 00000000000..896a84855a7 --- /dev/null +++ b/bundles/org.openhab.binding.airgradient/src/test/java/org/openhab/binding/airgradient/internal/handler/AirGradientAPIHandlerTest.java @@ -0,0 +1,119 @@ +/** + * Copyright (c) 2010-2024 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.airgradient.internal.handler; + +import static org.eclipse.jdt.annotation.Checks.requireNonNull; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.is; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; + +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.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.openhab.binding.airgradient.internal.communication.RemoteAPIController; +import org.openhab.binding.airgradient.internal.config.AirGradientAPIConfiguration; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.ThingStatusInfo; +import org.openhab.core.thing.binding.ThingHandlerCallback; + +import com.google.gson.Gson; + +/** + * @author Jørgen Austvik - Initial contribution + */ +@SuppressWarnings({ "null" }) +@NonNullByDefault +public class AirGradientAPIHandlerTest { + + private static final AirGradientAPIConfiguration TEST_CONFIG = new AirGradientAPIConfiguration() { + { + hostname = "abc123"; + token = "def456"; + } + }; + + private static final String MULTI_CONTENT = """ + [ + {"locationId":1234,"locationName":"Some Name","pm01":1,"pm02":2,"pm10":2,"pm003Count":536,"atmp":20.45,"rhum":16.61,"rco2":null,"tvoc":null,"wifi":-54,"timestamp":"2024-01-07T13:00:20.000Z","ledMode":"co2","ledCo2Threshold1":1000,"ledCo2Threshold2":2000,"ledCo2ThresholdEnd":4000,"serialno":"serial","firmwareVersion":null,"tvocIndex":null,"noxIndex":null}, + {"locationId":4321,"locationName":"Some other name","pm01":1,"pm02":2,"pm10":2,"pm003Count":536,"atmp":20.45,"rhum":16.61,"rco2":null,"tvoc":null,"wifi":-54,"timestamp":"2024-01-07T13:00:20.000Z","ledMode":"co2","ledCo2Threshold1":1000,"ledCo2Threshold2":2000,"ledCo2ThresholdEnd":4000,"serialno":"serial","firmwareVersion":null,"tvocIndex":null,"noxIndex":null} + ]"""; + + @Nullable + private AirGradientAPIHandler sut; + + @Nullable + Bridge bridge; + + @Nullable + HttpClient httpClientMock; + + @Nullable + Request requestMock; + + @BeforeEach + public void setUp() { + bridge = Mockito.mock(Bridge.class); + httpClientMock = Mockito.mock(HttpClient.class); + requestMock = Mockito.mock(Request.class); + + sut = new AirGradientAPIHandler(requireNonNull(bridge), requireNonNull(httpClientMock)); + sut.setConfiguration(TEST_CONFIG); + sut.setApiController(new RemoteAPIController(requireNonNull(httpClientMock), new Gson(), TEST_CONFIG)); + } + + @Test + public void testGetRegisteredNone() { + var res = sut.getRegisteredLocationIds(); + assertThat(res, is(empty())); + } + + @Test + public void testPollNoData() throws Exception { + ContentResponse response = Mockito.mock(ContentResponse.class); + Mockito.when(httpClientMock.newRequest(anyString())).thenReturn(requestMock); + Mockito.when(requestMock.send()).thenReturn(response); + Mockito.when(response.getStatus()).thenReturn(500); + ThingHandlerCallback callbackMock = Mockito.mock(ThingHandlerCallback.class); + + sut.setCallback(callbackMock); + sut.pollingCode(); + + verify(callbackMock).statusUpdated(requireNonNull(bridge), new ThingStatusInfo(ThingStatus.OFFLINE, + ThingStatusDetail.COMMUNICATION_ERROR, "Returned status code: 500")); + } + + @Test + public void testPollHasData() throws Exception { + ContentResponse response = Mockito.mock(ContentResponse.class); + Mockito.when(httpClientMock.newRequest(anyString())).thenReturn(requestMock); + Mockito.when(requestMock.send()).thenReturn(response); + Mockito.when(response.getStatus()).thenReturn(200); + Mockito.when(response.getContentAsString()).thenReturn(MULTI_CONTENT); + ThingHandlerCallback callbackMock = Mockito.mock(ThingHandlerCallback.class); + + sut.setCallback(callbackMock); + sut.pollingCode(); + + verify(callbackMock).statusUpdated(requireNonNull(bridge), + new ThingStatusInfo(ThingStatus.ONLINE, ThingStatusDetail.NONE, null)); + } +} diff --git a/bundles/org.openhab.binding.airgradient/src/test/java/org/openhab/binding/airgradient/internal/handler/AirGradientLocationHandlerTest.java b/bundles/org.openhab.binding.airgradient/src/test/java/org/openhab/binding/airgradient/internal/handler/AirGradientLocationHandlerTest.java new file mode 100644 index 00000000000..d0687866719 --- /dev/null +++ b/bundles/org.openhab.binding.airgradient/src/test/java/org/openhab/binding/airgradient/internal/handler/AirGradientLocationHandlerTest.java @@ -0,0 +1,105 @@ +/** + * Copyright (c) 2010-2024 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.airgradient.internal.handler; + +import static org.eclipse.jdt.annotation.Checks.requireNonNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.*; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.openhab.binding.airgradient.internal.model.Measure; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingUID; +import org.openhab.core.thing.binding.ThingHandlerCallback; +import org.openhab.core.types.UnDefType; + +/** + * @author Jørgen Austvik - Initial contribution + */ +@SuppressWarnings({ "null" }) +@NonNullByDefault +public class AirGradientLocationHandlerTest { + + private static final Measure TEST_MEASURE = new Measure() { + { + locationId = "12345"; + locationName = "Location name"; + pm01 = 2d; + pm02 = 3d; + pm10 = 4d; + pm003Count = 636d; + atmp = 19.63; + rhum = null; + rco2 = 455d; + tvoc = 51.644928; + wifi = -59d; + timestamp = "2024-01-07T11:28:56.000Z"; + serialno = "ecda3b1a2a50"; + firmwareVersion = "12345"; + tvocIndex = 1d; + noxIndex = 2d; + } + }; + + @Nullable + private AirGradientLocationHandler sut; + + @Nullable + private ThingHandlerCallback callbackMock; + + @Nullable + private Thing thing; + + @BeforeEach + public void setUp() { + callbackMock = Mockito.mock(ThingHandlerCallback.class); + Mockito.when(callbackMock.isChannelLinked(any(ChannelUID.class))).thenReturn(true); + thing = Mockito.mock(Thing.class); + + sut = new AirGradientLocationHandler(requireNonNull(thing)); + sut.setCallback(callbackMock); + + Mockito.when(thing.getUID()).thenReturn(new ThingUID(THING_TYPE_LOCATION, "1234")); + } + + @Test + public void testSetMeasure() { + sut.setCallback(callbackMock); + sut.setMeasurment(TEST_MEASURE); + + verify(callbackMock).stateUpdated(new ChannelUID(sut.getThing().getUID(), CHANNEL_WIFI), + new QuantityType<>("-59 dBm")); + verify(callbackMock).stateUpdated(new ChannelUID(sut.getThing().getUID(), CHANNEL_PM_01), + new QuantityType<>("2 µg/m³")); + verify(callbackMock).stateUpdated(new ChannelUID(sut.getThing().getUID(), CHANNEL_PM_02), + new QuantityType<>("3 µg/m³")); + verify(callbackMock).stateUpdated(new ChannelUID(sut.getThing().getUID(), CHANNEL_PM_10), + new QuantityType<>("4 µg/m³")); + verify(callbackMock).stateUpdated(new ChannelUID(sut.getThing().getUID(), CHANNEL_PM_003_COUNT), + new QuantityType<>("636")); + verify(callbackMock).stateUpdated(new ChannelUID(sut.getThing().getUID(), CHANNEL_ATMP), + new QuantityType<>("19.63 °C")); + verify(callbackMock).stateUpdated(new ChannelUID(sut.getThing().getUID(), CHANNEL_RHUM), UnDefType.NULL); + verify(callbackMock).stateUpdated(new ChannelUID(sut.getThing().getUID(), CHANNEL_RCO2), + new QuantityType<>("455 ppm")); + verify(callbackMock).stateUpdated(new ChannelUID(sut.getThing().getUID(), CHANNEL_TVOC), + new QuantityType<>("51 ppb")); + } +} diff --git a/bundles/org.openhab.binding.airgradient/src/test/java/org/openhab/binding/airgradient/internal/handler/RemoteApiControllerTest.java b/bundles/org.openhab.binding.airgradient/src/test/java/org/openhab/binding/airgradient/internal/handler/RemoteApiControllerTest.java new file mode 100644 index 00000000000..f71bc1bed7d --- /dev/null +++ b/bundles/org.openhab.binding.airgradient/src/test/java/org/openhab/binding/airgradient/internal/handler/RemoteApiControllerTest.java @@ -0,0 +1,262 @@ +/** + * Copyright (c) 2010-2024 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.airgradient.internal.handler; + +import static org.eclipse.jdt.annotation.Checks.requireNonNull; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.closeTo; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.mockito.ArgumentMatchers.anyString; + +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.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.openhab.binding.airgradient.internal.communication.AirGradientCommunicationException; +import org.openhab.binding.airgradient.internal.communication.RemoteAPIController; +import org.openhab.binding.airgradient.internal.config.AirGradientAPIConfiguration; + +import com.google.gson.Gson; + +/** + * @author Jørgen Austvik - Initial contribution + */ +@SuppressWarnings({ "null" }) +@NonNullByDefault +public class RemoteApiControllerTest { + + private static final AirGradientAPIConfiguration TEST_CONFIG = new AirGradientAPIConfiguration() { + { + hostname = "abc123"; + token = "def456"; + } + }; + + private static final String SINGLE_CONTENT = """ + {"locationId":4321,"locationName":"Some other name","pm01":1,"pm02":2,"pm10":2,"pm003Count":536,"atmp":20.45,"rhum":16.61,"rco2":456,"tvoc":51.644928,"wifi":-54,"timestamp":"2024-01-07T13:00:20.000Z","ledMode":"co2","ledCo2Threshold1":1000,"ledCo2Threshold2":2000,"ledCo2ThresholdEnd":4000,"serialno":"serial","firmwareVersion":null,"tvocIndex":null,"noxIndex":null} + """; + + private static final String MULTI_CONTENT = """ + [ + {"locationId":1234,"locationName":"Some Name","pm01":1,"pm02":2,"pm10":2,"pm003Count":536,"atmp":20.45,"rhum":16.61,"rco2":null,"tvoc":null,"wifi":-54,"timestamp":"2024-01-07T13:00:20.000Z","ledMode":"co2","ledCo2Threshold1":1000,"ledCo2Threshold2":2000,"ledCo2ThresholdEnd":4000,"serialno":"serial","firmwareVersion":null,"tvocIndex":null,"noxIndex":null}, + {"locationId":4321,"locationName":"Some other name","pm01":1,"pm02":2,"pm10":2,"pm003Count":536,"atmp":20.45,"rhum":16.61,"rco2":null,"tvoc":null,"wifi":-54,"timestamp":"2024-01-07T13:00:20.000Z","ledMode":"co2","ledCo2Threshold1":1000,"ledCo2Threshold2":2000,"ledCo2ThresholdEnd":4000,"serialno":"serial","firmwareVersion":null,"tvocIndex":null,"noxIndex":null} + ]"""; + private static final String MULTI_CONTENT2 = """ + [{"locationId":654321,"locationName":"xxxx","pm01":0,"pm02":1,"pm10":1,"pm003Count":null,"atmp":24.2,"rhum":18,"rco2":519,"tvoc":50.793266,"wifi":-62,"timestamp":"2024-02-01T19:15:37.000Z","ledMode":"co2","ledCo2Threshold1":1000,"ledCo2Threshold2":2000,"ledCo2ThresholdEnd":4000,"serialno":"ecda3b1a2a50","firmwareVersion":null,"tvocIndex":52,"noxIndex":1},{"locationId":123456,"locationName":"yyyy","pm01":0,"pm02":0,"pm10":0,"pm003Count":105,"atmp":22.33,"rhum":24,"rco2":468,"tvoc":130.95694,"wifi":-50,"timestamp":"2024-02-01T19:15:34.000Z","ledMode":"co2","ledCo2Threshold1":1000,"ledCo2Threshold2":2000,"ledCo2ThresholdEnd":4000,"serialno":"84fce612e644","firmwareVersion":null,"tvocIndex":137,"noxIndex":1}] + """; + + private static final String PROMETHEUS_CONTENT = """ + # HELP pm02 Particulate Matter PM2.5 value + # TYPE pm02 gauge + pm02{id="Airgradient"}6 + # HELP rco2 CO2 value, in ppm + # TYPE rco2 gauge + rco2{id="Airgradient"}862 + # HELP atmp Temperature, in degrees Celsius + # TYPE atmp gauge + atmp{id="Airgradient"}31.6 + # HELP rhum Relative humidity, in percent + # TYPE rhum gauge + rhum{id="Airgradient"}38 + # HELP tvoc Total volatile organic components, in μg/m³ + # TYPE tvoc gauge + tvoc{id="Airgradient"}51.644928 + # HELP nox, in μg/m³ + # TYPE nox gauge + nox{id="Airgradient"}1 + """; + + private static final String OPEN_METRICS_CONTENT = """ + # HELP airgradient_info AirGradient device information + # TYPE airgradient_info info + airgradient_info{airgradient_serial_number="4XXXXXXXXXXc",airgradient_device_type="ONE_I-9PSL",airgradient_library_version="3.0.4"} 1 + # HELP airgradient_config_ok 1 if the AirGradient device was able to successfully fetch its configuration from the server + # TYPE airgradient_config_ok gauge + airgradient_config_ok{} 1 + # HELP airgradient_post_ok 1 if the AirGradient device was able to successfully send to the server + # TYPE airgradient_post_ok gauge + airgradient_post_ok{} 1 + # HELP airgradient_wifi_rssi_dbm WiFi signal strength from the AirGradient device perspective, in dBm + # TYPE airgradient_wifi_rssi_dbm gauge + # UNIT airgradient_wifi_rssi_dbm dbm + airgradient_wifi_rssi_dbm{} -51 + # HELP airgradient_co2_ppm Carbon dioxide concentration as measured by the AirGradient S8 sensor, in parts per million + # TYPE airgradient_co2_ppm gauge + # UNIT airgradient_co2_ppm ppm + airgradient_co2_ppm{} 589 + # HELP airgradient_pm1_ugm3 PM1.0 concentration as measured by the AirGradient PMS sensor, in micrograms per cubic meter + # TYPE airgradient_pm1_ugm3 gauge + # UNIT airgradient_pm1_ugm3 ugm3 + airgradient_pm1_ugm3{} 3 + # HELP airgradient_pm2d5_ugm3 PM2.5 concentration as measured by the AirGradient PMS sensor, in micrograms per cubic meter + # TYPE airgradient_pm2d5_ugm3 gauge + # UNIT airgradient_pm2d5_ugm3 ugm3 + airgradient_pm2d5_ugm3{} 3 + # HELP airgradient_pm10_ugm3 PM10 concentration as measured by the AirGradient PMS sensor, in micrograms per cubic meter + # TYPE airgradient_pm10_ugm3 gauge + # UNIT airgradient_pm10_ugm3 ugm3 + airgradient_pm10_ugm3{} 3 + # HELP airgradient_pm0d3_p100ml PM0.3 concentration as measured by the AirGradient PMS sensor, in number of particules per 100 milliliters + # TYPE airgradient_pm0d3_p100ml gauge + # UNIT airgradient_pm0d3_p100ml p100ml + airgradient_pm0d3_p100ml{} 594 + # HELP airgradient_tvoc_index The processed Total Volatile Organic Compounds (TVOC) index as measured by the AirGradient SGP sensor + # TYPE airgradient_tvoc_index gauge + airgradient_tvoc_index{} 220 + # HELP airgradient_tvoc_raw_index The raw input value to the Total Volatile Organic Compounds (TVOC) index as measured by the AirGradient SGP sensor + # TYPE airgradient_tvoc_raw_index gauge + airgradient_tvoc_raw_index{} 30801 + # HELP airgradient_nox_index The processed Nitrous Oxide (NOx) index as measured by the AirGradient SGP sensor + # TYPE airgradient_nox_index gauge + airgradient_nox_index{} 1 + # HELP airgradient_temperature_degc The ambient temperature as measured by the AirGradient SHT sensor, in degrees Celsius + # TYPE airgradient_temperature_degc gauge + # UNIT airgradient_temperature_degc degc + airgradient_temperature_degc{} 23.69 + # HELP airgradient_humidity_percent The relative humidity as measured by the AirGradient SHT sensor + # TYPE airgradient_humidity_percent gauge + # UNIT airgradient_humidity_percent percent + airgradient_humidity_percent{} 39 + # EOF + """; + + @Nullable + private RemoteAPIController sut; + + @Nullable + HttpClient httpClientMock; + + @Nullable + Request requestMock; + + @BeforeEach + public void setUp() { + httpClientMock = Mockito.mock(HttpClient.class); + requestMock = Mockito.mock(Request.class); + + sut = new RemoteAPIController(requireNonNull(httpClientMock), new Gson(), TEST_CONFIG); + } + + @Test + public void testGetMeasuresNone() throws Exception { + ContentResponse response = Mockito.mock(ContentResponse.class); + Mockito.when(httpClientMock.newRequest(anyString())).thenReturn(requestMock); + Mockito.when(requestMock.send()).thenReturn(response); + Mockito.when(response.getMediaType()).thenReturn("application/json"); + Mockito.when(response.getStatus()).thenReturn(500); + + AirGradientCommunicationException agce = Assertions.assertThrows(AirGradientCommunicationException.class, + () -> sut.getMeasures()); + assertThat(agce.getMessage(), is("Returned status code: 500")); + } + + @Test + public void testGetMeasuresSingle() throws Exception { + ContentResponse response = Mockito.mock(ContentResponse.class); + Mockito.when(httpClientMock.newRequest(anyString())).thenReturn(requestMock); + Mockito.when(requestMock.send()).thenReturn(response); + Mockito.when(response.getStatus()).thenReturn(200); + Mockito.when(response.getMediaType()).thenReturn("application/json"); + Mockito.when(response.getContentAsString()).thenReturn(SINGLE_CONTENT); + + var res = sut.getMeasures(); + assertThat(res, is(not(empty()))); + assertThat(res.size(), is(1)); + assertThat(res.get(0).locationName, is("Some other name")); + } + + @Test + public void testGetMeasuresMulti() throws Exception { + ContentResponse response = Mockito.mock(ContentResponse.class); + Mockito.when(httpClientMock.newRequest(anyString())).thenReturn(requestMock); + Mockito.when(requestMock.send()).thenReturn(response); + Mockito.when(response.getStatus()).thenReturn(200); + Mockito.when(response.getMediaType()).thenReturn("application/json"); + Mockito.when(response.getContentAsString()).thenReturn(MULTI_CONTENT); + + var res = sut.getMeasures(); + assertThat(res, is(not(empty()))); + assertThat(res.size(), is(2)); + assertThat(res.get(0).locationName, is("Some Name")); + assertThat(res.get(1).locationName, is("Some other name")); + } + + @Test + public void testGetMeasuresMulti2() throws Exception { + ContentResponse response = Mockito.mock(ContentResponse.class); + Mockito.when(httpClientMock.newRequest(anyString())).thenReturn(requestMock); + Mockito.when(requestMock.send()).thenReturn(response); + Mockito.when(response.getStatus()).thenReturn(200); + Mockito.when(response.getMediaType()).thenReturn("application/json"); + Mockito.when(response.getContentAsString()).thenReturn(MULTI_CONTENT2); + + var res = sut.getMeasures(); + assertThat(res, is(not(empty()))); + assertThat(res.size(), is(2)); + assertThat(res.get(0).locationName, is("xxxx")); + assertThat(res.get(1).locationName, is("yyyy")); + } + + @Test + public void testGetMeasuresPrometheus() throws Exception { + ContentResponse response = Mockito.mock(ContentResponse.class); + Mockito.when(httpClientMock.newRequest(anyString())).thenReturn(requestMock); + Mockito.when(requestMock.send()).thenReturn(response); + Mockito.when(response.getStatus()).thenReturn(200); + Mockito.when(response.getMediaType()).thenReturn("text/plain"); + Mockito.when(response.getContentAsString()).thenReturn(PROMETHEUS_CONTENT); + + var res = sut.getMeasures(); + assertThat(res, is(not(empty()))); + assertThat(res.size(), is(1)); + assertThat(res.get(0).pm02, closeTo(6, 0.1)); + assertThat(res.get(0).rco2, closeTo(862, 0.1)); + assertThat(res.get(0).atmp, closeTo(31.6, 0.1)); + assertThat(res.get(0).rhum, closeTo(38, 0.1)); + assertThat(res.get(0).tvoc, closeTo(51.644928, 0.1)); + assertThat(res.get(0).noxIndex, closeTo(1, 0.1)); + assertThat(res.get(0).locationId, is("Airgradient")); + assertThat(res.get(0).locationName, is("Airgradient")); + assertThat(res.get(0).serialno, is("Airgradient")); + } + + @Test + public void testGetMeasuresOpenMetrics() throws Exception { + ContentResponse response = Mockito.mock(ContentResponse.class); + Mockito.when(httpClientMock.newRequest(anyString())).thenReturn(requestMock); + Mockito.when(requestMock.send()).thenReturn(response); + Mockito.when(response.getStatus()).thenReturn(200); + Mockito.when(response.getMediaType()).thenReturn("application/openmetrics-text"); + Mockito.when(response.getContentAsString()).thenReturn(OPEN_METRICS_CONTENT); + + var res = sut.getMeasures(); + assertThat(res, is(not(empty()))); + assertThat(res.size(), is(1)); + assertThat(res.get(0).pm01, closeTo(3, 0.1)); + assertThat(res.get(0).pm02, closeTo(3, 0.1)); + assertThat(res.get(0).pm10, closeTo(3, 0.1)); + assertThat(res.get(0).rco2, closeTo(589, 0.1)); + assertThat(res.get(0).atmp, closeTo(23.69, 0.1)); + assertThat(res.get(0).rhum, closeTo(39, 0.1)); + assertThat(res.get(0).tvoc, closeTo(220, 0.1)); + assertThat(res.get(0).noxIndex, closeTo(1, 0.1)); + assertThat(res.get(0).serialno, is("4XXXXXXXXXXc")); + } +} diff --git a/bundles/org.openhab.binding.airgradient/src/test/java/org/openhab/binding/airgradient/internal/prometheus/PrometheusMetricTest.java b/bundles/org.openhab.binding.airgradient/src/test/java/org/openhab/binding/airgradient/internal/prometheus/PrometheusMetricTest.java new file mode 100644 index 00000000000..9ae18e432ec --- /dev/null +++ b/bundles/org.openhab.binding.airgradient/src/test/java/org/openhab/binding/airgradient/internal/prometheus/PrometheusMetricTest.java @@ -0,0 +1,77 @@ +/** + * Copyright (c) 2010-2024 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.airgradient.internal.prometheus; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.closeTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; + +import java.time.Instant; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; + +/** + * @author Jørgen Austvik - Initial contribution + */ +@SuppressWarnings({ "null" }) +@NonNullByDefault +public class PrometheusMetricTest { + + @Test + public void testParseEmpty() { + var res = PrometheusMetric.parse(""); + assertThat(res, is(nullValue())); + } + + @Test + public void testParseComment() { + var res = PrometheusMetric.parse("# Comment"); + assertThat(res, is(nullValue())); + } + + @Test + public void testParseAirGradient() { + var res = PrometheusMetric.parse("atmp{id=\"Airgradient\"}31.6"); + assertThat(res.getMetricName(), is("atmp")); + assertThat(res.getValue(), closeTo(31.6, 0.1)); + assertThat(res.getLabels().get("id"), is("Airgradient")); + } + + @Test + public void testParseNoLables() { + var res = PrometheusMetric.parse("http_request_duration_seconds_count 144320"); + assertThat(res.getMetricName(), is("http_request_duration_seconds_count")); + assertThat(res.getValue(), closeTo(144320, 0.1)); + } + + @Test + public void testParseWithTimestamp() { + var res = PrometheusMetric.parse("http_requests_total{method=\"post\",code=\"200\"} 1027 1395066363000"); + assertThat(res.getMetricName(), is("http_requests_total")); + assertThat(res.getValue(), closeTo(1027, 0.1)); + assertThat(res.getTimeStamp(), is(Instant.ofEpochMilli(1395066363000L))); + assertThat(res.getLabels().get("method"), is("post")); + assertThat(res.getLabels().get("code"), is("200")); + } + + @Test + public void testParseNegativeEpoch() { + var res = PrometheusMetric.parse("something_weird{problem=\"division by zero\"} 123 -3982045"); + assertThat(res.getMetricName(), is("something_weird")); + assertThat(res.getTimeStamp(), is(Instant.ofEpochMilli(-3982045))); + assertThat(res.getValue(), closeTo(123, 0.1)); + assertThat(res.getLabels().get("problem"), is("division by zero")); + } +} diff --git a/bundles/pom.xml b/bundles/pom.xml index 701b03ffb03..f83ee4d8bab 100644 --- a/bundles/pom.xml +++ b/bundles/pom.xml @@ -46,6 +46,7 @@ org.openhab.binding.adorne org.openhab.binding.ahawastecollection + org.openhab.binding.airgradient org.openhab.binding.airq org.openhab.binding.airquality org.openhab.binding.airvisualnode