[airgradient] Initial contribution (#16584)

* [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 <jaustvik@acm.org>
This commit is contained in:
Jørgen Austvik 2024-05-10 00:10:37 +02:00 committed by GitHub
parent 677fd35d02
commit 6efe62fe70
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 3033 additions and 0 deletions

View File

@ -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

View File

@ -61,6 +61,11 @@
<artifactId>org.openhab.binding.ahawastecollection</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.airgradient</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.airq</artifactId>

View File

@ -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

View File

@ -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³" <density> {channel="airgradient:location:654321:pm2"}"
Number:Temperature AirGradient_Location_PM2 "Temperature [%.1f °C]" <temperature> {channel="airgradient:location:654321:atmp"}"
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
<version>4.2.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.binding.airgradient</artifactId>
<name>openHAB Add-ons :: Bundles :: AirGradient Binding</name>
</project>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<features name="org.openhab.binding.airgradient-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
<repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
<feature name="openhab-binding-airgradient" description="AirGradient Binding" version="${project.version}">
<feature>openhab-runtime-base</feature>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.airgradient/${project.version}</bundle>
</feature>
</features>

View File

@ -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);
}

View File

@ -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<ThingTypeUID> 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;
}
}

View File

@ -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);
}
}

View File

@ -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<Measure> 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<List<@Nullable Measure>>() {
}.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<Measure>() {
}.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<Measure> measuresWithoutNulls = new ArrayList<>(nullableMeasuresWithoutNulls.size());
for (@Nullable
Measure m : nullableMeasuresWithoutNulls) {
if (m != null) {
measuresWithoutNulls.add(m);
}
}
return measuresWithoutNulls;
}
return Collections.emptyList();
}
}

View File

@ -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<Measure> parsePrometheus(String stringResponse) {
List<PrometheusMetric> 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);
}
}

View File

@ -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;
}
}

View File

@ -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<Measure> 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;
}
}

View File

@ -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("/");
}
}

View File

@ -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 = "";
}

View File

@ -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<Measure> 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<String> 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<String, Object> 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<Measure> 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();
}
}

View File

@ -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<ThingTypeUID> 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<String, Object> 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);
}
}

View File

@ -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<PollEventListener> 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<Measure> measures = apiController.getMeasures();
updateStatus(ThingStatus.ONLINE);
triggerPollEvent(measures);
Map<String, Measure> 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<String> getRegisteredLocationIds() {
List<Thing> things = getThing().getThings();
List<String> 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<Measure> measures) {
for (PollEventListener listener : pollListeners) {
listener.pollEvent(measures);
}
}
// Discovery
@Override
public Collection<Class<? extends ThingHandlerService>> getServices() {
return Set.of(AirGradientLocationDiscoveryService.class);
}
}

View File

@ -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<Measure> 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<String, State> states = MeasureHelper.createStates(measures.get(0));
for (Map.Entry<String, State> 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;
}
}

View File

@ -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<String, State> states = MeasureHelper.createStates(measure);
for (Map.Entry<String, State> 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;
}
}

View File

@ -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<String, String> createProperties(Measure measure) {
Map<String, String> 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<String, State> createStates(Measure measure) {
Map<String, State> 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);
}
}

View File

@ -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<Measure> measures);
}

View File

@ -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;
}

View File

@ -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
}

View File

@ -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<String, String> labels;
public PrometheusMetric(String metricName, double value, Instant timestamp, Map<String, String> 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<String, String> parseLabels(String labelPart) {
String[] labels = labelPart.split(",");
Map<String, String> 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<String, String> getLabels() {
return labels;
}
}

View File

@ -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<PrometheusMetric> parse(String text) {
String[] lines = text.split("\\r?\\n");
List<PrometheusMetric> metrics = new ArrayList<>(lines.length);
for (String line : lines) {
@Nullable
PrometheusMetric metric = PrometheusMetric.parse(line);
if (metric != null) {
metrics.add(metric);
}
}
return metrics;
}
}

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<addon:addon id="airgradient" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:addon="https://openhab.org/schemas/addon/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/addon/v1.0.0 https://openhab.org/schemas/addon-1.0.0.xsd">
<type>binding</type>
<name>AirGradient Binding</name>
<description>This is the binding for AirGradient air quality sensors.</description>
<connection>hybrid</connection>
<!-- <countries/> All -->
<discovery-methods>
<discovery-method>
<service-type>mdns</service-type>
<discovery-parameters>
<discovery-parameter>
<name>mdnsServiceType</name>
<value>_airgradient._tcp.local.</value>
</discovery-parameter>
</discovery-parameters>
</discovery-method>
</discovery-methods>
</addon:addon>

View File

@ -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

View File

@ -0,0 +1,202 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="airgradient"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<!-- A connection to the Cloud API which can have several locations (sensors) connected -->
<bridge-type id="airgradient-api">
<label>AirGradient API</label>
<description>Connection to the AirGradient Cloud API</description>
<representation-property>token</representation-property>
<config-description>
<parameter name="token" type="text" required="false">
<context>password</context>
<label>Token</label>
<description>Token to access the device</description>
</parameter>
<parameter name="hostname" type="text">
<context>network-address</context>
<label>Hostname</label>
<default>https://api.airgradient.com/</default>
<description>Hostname or IP address of the API</description>
<advanced>true</advanced>
</parameter>
<parameter name="refreshInterval" type="integer" unit="s" min="1">
<label>Refresh Interval</label>
<description>Interval the device is polled in sec.</description>
<default>600</default>
<advanced>true</advanced>
</parameter>
</config-description>
</bridge-type>
<!-- A sensor you communicate directly to over the local network -->
<thing-type id="airgradient-local">
<label>AirGradient Local Sensor</label>
<description>Direct network connection to a local AirGradient Sensor</description>
<channels>
<channel id="pm01" typeId="pm1"/>
<channel id="pm02" typeId="pm2"/>
<channel id="pm10" typeId="pm10"/>
<channel id="pm003-count" typeId="particle-count"/>
<channel id="atmp" typeId="system.outdoor-temperature"/>
<channel id="rhum" typeId="system.atmospheric-humidity"/>
<channel id="wifi" typeId="wifi"/>
<channel id="rco2" typeId="co2"/>
<channel id="tvoc" typeId="tvoc"/>
<channel id="leds" typeId="leds-mode"/>
<channel id="calibration" typeId="calibration"/>
<channel id="uploads-since-boot" typeId="uploads-since-boot"/>
</channels>
<properties>
<property name="name"/>
<property name="firmwareVersion"/>
<property name="serialNumber"/>
<property name="modelId"/>
</properties>
<representation-property>serialNumber</representation-property>
<config-description>
<parameter name="hostname" type="text">
<context>network-address</context>
<label>Hostname</label>
<default>http://192.168.1.1:80/measures/current</default>
<description>Hostname or IP address of the API</description>
<advanced>false</advanced>
</parameter>
<parameter name="refreshInterval" type="integer" unit="s" min="1">
<label>Refresh Interval</label>
<description>Interval the device is polled in sec.</description>
<default>10</default>
<advanced>true</advanced>
</parameter>
</config-description>
</thing-type>
<!-- Sensors are called locations in the Cloud API -->
<thing-type id="location">
<supported-bridge-type-refs>
<bridge-type-ref id="airgradient-api"/>
</supported-bridge-type-refs>
<label>AirGradient Location</label>
<description>AirGradient Location for data from the AirGradient Cloud API</description>
<channels>
<channel id="pm01" typeId="pm1"/>
<channel id="pm02" typeId="pm2"/>
<channel id="pm10" typeId="pm10"/>
<channel id="pm003-count" typeId="particle-count"/>
<channel id="atmp" typeId="system.outdoor-temperature"/>
<channel id="rhum" typeId="system.atmospheric-humidity"/>
<channel id="wifi" typeId="wifi"/>
<channel id="rco2" typeId="co2"/>
<channel id="tvoc" typeId="tvoc"/>
<channel id="leds" typeId="leds-mode"/>
<channel id="calibration" typeId="calibration"/>
<channel id="uploads-since-boot" typeId="uploads-since-boot"/>
</channels>
<properties>
<property name="name"/>
<property name="firmwareVersion"/>
<property name="serialNumber"/>
<property name="modelId"/>
</properties>
<representation-property>location</representation-property>
<config-description>
<parameter name="location" type="text" required="true">
<label>Location ID</label>
<description>ID of the location</description>
</parameter>
</config-description>
</thing-type>
<channel-type id="pm1">
<item-type>Number:Density</item-type>
<label>PM1</label>
<description>Particulate Matter 1 (0.001mm)</description>
<state readOnly="true" pattern="%d %unit%"/>
</channel-type>
<channel-type id="pm2">
<item-type>Number:Density</item-type>
<label>PM2</label>
<description>Particulate Matter 2 (0.002mm)</description>
<state readOnly="true" pattern="%d %unit%"/>
</channel-type>
<channel-type id="pm10">
<item-type>Number:Density</item-type>
<label>PM10</label>
<description>Particulate Matter 10 (0.01mm)</description>
<state readOnly="true" pattern="%d %unit%"/>
</channel-type>
<channel-type id="particle-count">
<item-type>Number:Dimensionless</item-type>
<label>Particle Count</label>
<description>Count of particles in 1 decilitre of air</description>
<state readOnly="true" pattern="%d"/>
</channel-type>
<channel-type id="wifi">
<item-type>Number</item-type>
<label>RSSI</label>
<description>Received signal strength indicator</description>
<category>QualityOfService</category>
<state readOnly="true" pattern="%d dBm"/>
</channel-type>
<channel-type id="co2">
<item-type>Number:Dimensionless</item-type>
<label>CO2</label>
<description>CarbonDioxide</description>
<state readOnly="true" pattern="%d %unit%"/>
</channel-type>
<channel-type id="tvoc">
<item-type>Number:Dimensionless</item-type>
<label>TVOC</label>
<description>Total Volatile Organic Compounds</description>
<state readOnly="true" pattern="%d %unit%"/>
</channel-type>
<channel-type id="uploads-since-boot">
<item-type>Number:Dimensionless</item-type>
<label>Upload count</label>
<description>Number of uploads since last reboot (boot)</description>
<state readOnly="true" pattern="%d"/>
</channel-type>
<channel-type id="leds-mode">
<item-type>String</item-type>
<label>LEDs Mode</label>
<description>Mode for the LEDs</description>
<state readOnly="false">
<options>
<option value="default">default</option>
<option value="off">off</option>
<option value="pm">pm</option>
<option value="co2">co2</option>
</options>
</state>
</channel-type>
<channel-type id="calibration">
<item-type>String</item-type>
<label>Calibration</label>
<description>Calibrate Sensors</description>
<command>
<options>
<option value="co2">co2</option>
</options>
</command>
</channel-type>
</thing:thing-descriptions>

View File

@ -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()));
}
}

View File

@ -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));
}
}

View File

@ -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"));
}
}

View File

@ -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"));
}
}

View File

@ -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"));
}
}

View File

@ -46,6 +46,7 @@
<!-- bindings -->
<module>org.openhab.binding.adorne</module>
<module>org.openhab.binding.ahawastecollection</module>
<module>org.openhab.binding.airgradient</module>
<module>org.openhab.binding.airq</module>
<module>org.openhab.binding.airquality</module>
<module>org.openhab.binding.airvisualnode</module>