mirror of
https://github.com/openhab/openhab-addons.git
synced 2025-01-10 07:02:02 +01:00
[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:
parent
677fd35d02
commit
6efe62fe70
@ -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
|
||||
|
@ -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>
|
||||
|
13
bundles/org.openhab.binding.airgradient/NOTICE
Normal file
13
bundles/org.openhab.binding.airgradient/NOTICE
Normal 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
|
100
bundles/org.openhab.binding.airgradient/README.md
Normal file
100
bundles/org.openhab.binding.airgradient/README.md
Normal 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 |
17
bundles/org.openhab.binding.airgradient/pom.xml
Normal file
17
bundles/org.openhab.binding.airgradient/pom.xml
Normal 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>
|
@ -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>
|
@ -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);
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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("/");
|
||||
}
|
||||
}
|
@ -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 = "";
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
@ -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;
|
||||
}
|
@ -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
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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>
|
@ -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
|
@ -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>
|
@ -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()));
|
||||
}
|
||||
}
|
@ -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));
|
||||
}
|
||||
}
|
@ -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"));
|
||||
}
|
||||
}
|
@ -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"));
|
||||
}
|
||||
}
|
@ -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"));
|
||||
}
|
||||
}
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user