]> git.basschouten.com Git - openhab-addons.git/commitdiff
[airgradient] Support firmware v3.1.1 and later (#16851)
authorJørgen Austvik <jaustvik@acm.org>
Mon, 10 Jun 2024 21:15:08 +0000 (23:15 +0200)
committerGitHub <noreply@github.com>
Mon, 10 Jun 2024 21:15:08 +0000 (23:15 +0200)
* Support calibrated measurements from firmware v3.1.1

Signed-off-by: Jørgen Austvik <jaustvik@acm.org>
13 files changed:
bundles/org.openhab.binding.airgradient/README.md
bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/AirGradientBindingConstants.java
bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/communication/RESTHelper.java
bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/communication/RemoteAPIController.java
bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/AirGradientLocalHandler.java
bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/ConfigurationHelper.java [new file with mode: 0644]
bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/DynamicChannelHelper.java [new file with mode: 0644]
bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/MeasureHelper.java
bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/model/LocalConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/model/Measure.java
bundles/org.openhab.binding.airgradient/src/main/resources/OH-INF/thing/thing-types.xml
bundles/org.openhab.binding.airgradient/src/test/java/org/openhab/binding/airgradient/internal/handler/AirGradientLocationHandlerTest.java
bundles/org.openhab.binding.airgradient/src/test/java/org/openhab/binding/airgradient/internal/handler/RemoteApiControllerTest.java

index 2a901f9885ff128425a3eb34f2749b39a73b3dcc..b29157ddabbc4b1f7b623832f396aa66c5a116e4 100644 (file)
@@ -67,18 +67,40 @@ To add a location, you need to know the location ID. To get the location ID, you
 
 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)                               |
+| 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        | Number:Dimensionless | 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                                               |
+| uploads-since-boot | Number:Dimensionless | Read       | Number of measure uploads since last reboot (boot)                               |
+| leds               | String               | Read/Write | Sets the leds mode (off/co2/pm)                                                  |
+| calibration        | String               | Write      | Triggers co2 calibration on the device                                           |
+
+Some configuration channels are only available for local devices (for cloud devices use the AirGradient dashboard to configure these instead).
+These configuration settings needs AirGradient firmware on the sensor of version 3.1.1 or later.
+
+| Channel               | Type                 | Read/Write | Description                                                                      |
+|-----------------------|----------------------|------------|----------------------------------------------------------------------------------|
+| country-code          | String               | Read/Write | The ALPHA-2 Country code used for the device                                     |
+| pm-standard           | String               | Read/Write | Standard used for Parts per Million measurements (us-aqi or ugm3)                |
+| abc-days              | Number:Days          | Read/Write | Co2 calibration automatic baseline calibration days                              |
+| tvoc-learning-offset  | Number:Dimensionless | Read/Write | Time constant of long-term estimator for offset.                                 |
+| nox-learning-offset   | Number:Dimensionless | Read/Write | Time constant of long-term estimator for offset.                                 |
+| mqtt-broker-url       | String               | Read/Write | MQTT Broker URL                                                                  |
+| temperature-unit      | String               | Read/Write | Temperature unit used on the display                                             |
+| configuration-control | String               | Read/Write | Where the unit is configured from (local/cloud/both)                             |
+| post-to-cloud         | Switch               | Read/Write | Send data to the AirGradient cloud                                               |
+| led-bar-brightness    | Number:Dimensionless | Read/Write | Brightness of the LED bar                                                        |
+| display-brightness    | Number:Dimensionless | Read/Write | Brightness of the display                                                        |
+| model                 | String               | Read/Write | The model of the device (can be changed e.g. if you change sensors)              |
+| led-bar-test          | String               | Write      | Trigger test of LED bar                                                          |
+
 
 ## Full Example
 
index 839e5d7c6ba92cd17f31d4ca526decf3a12d6c85..af0a37bbd11d384b5dcccfb897bc50c4ec3562a2 100644 (file)
@@ -26,7 +26,7 @@ import org.openhab.core.thing.ThingTypeUID;
 @NonNullByDefault
 public class AirGradientBindingConstants {
 
-    private static final String BINDING_ID = "airgradient";
+    public 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");
@@ -46,6 +46,19 @@ public class AirGradientBindingConstants {
     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";
+    public static final String CHANNEL_COUNTRY_CODE = "country-code";
+    public static final String CHANNEL_PM_STANDARD = "pm-standard";
+    public static final String CHANNEL_ABC_DAYS = "abc-days";
+    public static final String CHANNEL_TVOC_LEARNING_OFFSET = "tvoc-learning-offset";
+    public static final String CHANNEL_NOX_LEARNING_OFFSET = "nox-learning-offset";
+    public static final String CHANNEL_MQTT_BROKER_URL = "mqtt-broker-url";
+    public static final String CHANNEL_TEMPERATURE_UNIT = "temperature-unit";
+    public static final String CHANNEL_CONFIGURATION_CONTROL = "configuration-control";
+    public static final String CHANNEL_POST_TO_CLOUD = "post-to-cloud";
+    public static final String CHANNEL_LED_BAR_BRIGHTNESS = "led-bar-brightness";
+    public static final String CHANNEL_DISPLAY_BRIGHTNESS = "display-brightness";
+    public static final String CHANNEL_MODEL = "model";
+    public static final String CHANNEL_LED_BAR_TEST = "led-bar-test";
 
     // List of all properties
     public static final String PROPERTY_NAME = "name";
@@ -59,6 +72,7 @@ public class AirGradientBindingConstants {
     // 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 LOCAL_CONFIG_PATH = "/config";
     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";
 
index a522e1aa06d5f067400b84b5758df203261c5e6b..04db1ab3c1e2f932e523e868797fd16f38f1b3c0 100644 (file)
@@ -15,8 +15,10 @@ 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.LOCAL_CONFIG_PATH;
 import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.REQUEST_TIMEOUT;
 
+import java.net.URI;
 import java.util.concurrent.TimeUnit;
 
 import org.eclipse.jdt.annotation.NonNullByDefault;
@@ -42,11 +44,17 @@ public class RESTHelper {
         }
     }
 
+    public static @Nullable String generateConfigUrl(AirGradientAPIConfiguration apiConfig) {
+        URI uri = URI.create(apiConfig.hostname);
+        URI configUri = uri.resolve(LOCAL_CONFIG_PATH);
+        return configUri.toString();
+    }
+
     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;
+            return generateConfigUrl(apiConfig);
         }
     }
 
@@ -54,7 +62,7 @@ public class RESTHelper {
         if (apiConfig.hasCloudUrl()) {
             return apiConfig.hostname + String.format(LEDS_MODE_PATH, serialNo, apiConfig.token);
         } else {
-            return apiConfig.hostname;
+            return generateConfigUrl(apiConfig);
         }
     }
 
index d4d8a6ba4c6e17865cf8e5b09bdd7b25090d3faf..47c8f716443a2a1a0545171cba90c172d1db6d6b 100644 (file)
@@ -17,6 +17,7 @@ import static org.openhab.binding.airgradient.internal.AirGradientBindingConstan
 import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.CONTENTTYPE_TEXT;
 import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.REQUEST_TIMEOUT;
 
+import java.lang.reflect.Type;
 import java.nio.charset.StandardCharsets;
 import java.util.Collections;
 import java.util.List;
@@ -35,11 +36,13 @@ 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.LocalConfiguration;
 import org.openhab.binding.airgradient.internal.model.Measure;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
 
 /**
  * Helper for doing rest calls to the AirGradient API.
@@ -72,7 +75,7 @@ public class RemoteAPIController {
                 RESTHelper.generateRequest(httpClient, RESTHelper.generateMeasuresUrl(apiConfig)));
         if (response != null) {
             String contentType = response.getMediaType();
-            logger.debug("Got measurements with status {}: {} ({})", response.getStatus(),
+            logger.trace("Got measurements with status {}: {} ({})", response.getStatus(),
                     response.getContentAsString(), contentType);
 
             if (HttpStatus.isSuccess(response.getStatus())) {
@@ -96,6 +99,31 @@ public class RemoteAPIController {
         return Collections.emptyList();
     }
 
+    public @Nullable LocalConfiguration getConfig() throws AirGradientCommunicationException {
+        ContentResponse response = sendRequest(
+                RESTHelper.generateRequest(httpClient, RESTHelper.generateConfigUrl(apiConfig)));
+        if (response == null) {
+            return null;
+        }
+
+        logger.trace("Got configuration with status {}: {}", response.getStatus(), response.getContentAsString());
+
+        Type configType = new TypeToken<LocalConfiguration>() {
+        }.getType();
+        return gson.fromJson(response.getContentAsString(), configType);
+    }
+
+    public void setConfig(LocalConfiguration config) throws AirGradientCommunicationException {
+        Request request = httpClient.newRequest(RESTHelper.generateConfigUrl(apiConfig));
+        request.timeout(REQUEST_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        request.method(HttpMethod.PUT);
+        request.header(HttpHeader.CONTENT_TYPE, CONTENTTYPE_JSON);
+        String configJson = gson.toJson(config);
+        logger.debug("Setting configuration: {}", configJson);
+        request.content(new StringContentProvider(CONTENTTYPE_JSON, configJson, StandardCharsets.UTF_8));
+        sendRequest(request);
+    }
+
     public void setLedMode(String serialNo, String mode) throws AirGradientCommunicationException {
         Request request = httpClient.newRequest(RESTHelper.generateGetLedsModeUrl(apiConfig, serialNo));
         request.timeout(REQUEST_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
@@ -126,7 +154,8 @@ public class RemoteAPIController {
         try {
             response = request.send();
             if (response != null) {
-                logger.debug("Response from {}: {}", request.getURI(), response.getStatus());
+                logger.trace("Response from {} ({}): {}", request.getURI(), response.getStatus(),
+                        response.getContentAsString());
                 if (!HttpStatus.isSuccess(response.getStatus())) {
                     throw new AirGradientCommunicationException("Returned status code: " + response.getStatus());
                 }
index 03878c26b711296ddc73b11221c4141111ce144f..7703f17197f3afa4fa0ef554bd0fc4a95bbff033 100644 (file)
  */
 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 static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.*;
 
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.ScheduledFuture;
 import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
 
 import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.eclipse.jdt.annotation.Nullable;
@@ -26,13 +26,17 @@ 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.LocalConfiguration;
 import org.openhab.binding.airgradient.internal.model.Measure;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.QuantityType;
 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.thing.binding.builder.ThingBuilder;
 import org.openhab.core.types.Command;
 import org.openhab.core.types.RefreshType;
 import org.openhab.core.types.State;
@@ -72,7 +76,7 @@ public class AirGradientLocalHandler extends BaseThingHandler {
             pollingCode();
         } else if (CHANNEL_LEDS_MODE.equals(channelUID.getId())) {
             if (command instanceof StringType stringCommand) {
-                setLedModeOnDevice(stringCommand.toFullString());
+                updateConfiguration((var c) -> c.ledBarMode = stringCommand.toFullString());
             } else {
                 logger.warn("Received command {} for channel {}, but it needs a string command", command.toString(),
                         channelUID.getId());
@@ -80,17 +84,63 @@ public class AirGradientLocalHandler extends BaseThingHandler {
         } else if (CHANNEL_CALIBRATION.equals(channelUID.getId())) {
             if (command instanceof StringType stringCommand) {
                 if ("co2".equals(stringCommand.toFullString())) {
-                    calibrateCo2OnDevice();
+                    updateConfiguration((var c) -> c.co2CalibrationRequested = true);
                 } else {
                     logger.warn(
                             "Received unknown command {} for calibration on channel {}, which we don't know how to handle",
                             command.toString(), channelUID.getId());
                 }
             }
+        } else if (CHANNEL_TEMPERATURE_UNIT.equals(channelUID.getId())) {
+            if (command instanceof StringType stringCommand) {
+                updateConfiguration((var c) -> c.temperatureUnit = stringCommand.toFullString());
+            }
+        } else if (CHANNEL_PM_STANDARD.equals(channelUID.getId())) {
+            if (command instanceof StringType stringCommand) {
+                updateConfiguration((var c) -> c.pmStandard = stringCommand.toFullString());
+            }
+        } else if (CHANNEL_ABC_DAYS.equals(channelUID.getId())) {
+            if (command instanceof QuantityType quantityCommand) {
+                updateConfiguration((var c) -> c.abcDays = quantityCommand.longValue());
+            }
+        } else if (CHANNEL_TVOC_LEARNING_OFFSET.equals(channelUID.getId())) {
+            if (command instanceof QuantityType quantityCommand) {
+                updateConfiguration((var c) -> c.tvocLearningOffset = quantityCommand.longValue());
+            }
+        } else if (CHANNEL_NOX_LEARNING_OFFSET.equals(channelUID.getId())) {
+            if (command instanceof QuantityType quantityCommand) {
+                updateConfiguration((var c) -> c.noxLearningOffset = quantityCommand.longValue());
+            }
+        } else if (CHANNEL_MQTT_BROKER_URL.equals(channelUID.getId())) {
+            if (command instanceof StringType stringCommand) {
+                updateConfiguration((var c) -> c.mqttBrokerUrl = stringCommand.toFullString());
+            }
+        } else if (CHANNEL_CONFIGURATION_CONTROL.equals(channelUID.getId())) {
+            if (command instanceof StringType stringCommand) {
+                updateConfiguration((var c) -> c.configurationControl = stringCommand.toFullString());
+            }
+        } else if (CHANNEL_LED_BAR_BRIGHTNESS.equals(channelUID.getId())) {
+            if (command instanceof QuantityType quantityCommand) {
+                updateConfiguration((var c) -> c.ledBarBrightness = quantityCommand.longValue());
+            }
+        } else if (CHANNEL_DISPLAY_BRIGHTNESS.equals(channelUID.getId())) {
+            if (command instanceof QuantityType quantityCommand) {
+                updateConfiguration((var c) -> c.displayBrightness = quantityCommand.longValue());
+            }
+        } else if (CHANNEL_POST_TO_CLOUD.equals(channelUID.getId())) {
+            if (command instanceof OnOffType onOffCommand) {
+                updateConfiguration((var c) -> c.postDataToAirGradient = onOffCommand.equals(OnOffType.ON));
+            }
+        } else if (CHANNEL_MODEL.equals(channelUID.getId())) {
+            if (command instanceof StringType stringCommand) {
+                updateConfiguration((var c) -> c.model = stringCommand.toFullString());
+            }
+        } else if (CHANNEL_LED_BAR_TEST.equals(channelUID.getId())) {
+            updateConfiguration((var c) -> c.ledBarTestRequested = true);
         } else {
             // This is read only
-            logger.warn("Received command {} for channel {}, which we don't know how to handle", command.toString(),
-                    channelUID.getId());
+            logger.warn("Received command {} for channel {}, which we don't know how to handle (type: {})",
+                    command.toString(), channelUID.getId(), command.getClass());
         }
     }
 
@@ -124,50 +174,46 @@ public class AirGradientLocalHandler extends BaseThingHandler {
                 return;
             }
 
-            updateProperties(MeasureHelper.createProperties(measures.get(0)));
-            Map<String, State> states = MeasureHelper.createStates(measures.get(0));
+            Measure measure = measures.get(0);
+            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());
                 }
             }
-        } catch (AirGradientCommunicationException agce) {
-            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, agce.getMessage());
-        }
-    }
 
-    private void setLedModeOnDevice(String mode) {
-        try {
-            apiController.setLedMode(getSerialNo(), mode);
-            updateStatus(ThingStatus.ONLINE);
+            LocalConfiguration localConfig = apiController.getConfig();
+            if (localConfig != null) {
+                // If we are able to read config, we add config channels
+                ThingBuilder builder = DynamicChannelHelper.updateThingWithConfigurationChannels(thing, editThing());
+                updateThing(builder.build());
+
+                updateProperties(ConfigurationHelper.createProperties(localConfig));
+                Map<String, State> configStates = ConfigurationHelper.createStates(localConfig);
+                for (Map.Entry<String, State> entry : configStates.entrySet()) {
+                    if (isLinked(entry.getKey())) {
+                        updateState(entry.getKey(), entry.getValue());
+                    }
+                }
+            }
+
         } catch (AirGradientCommunicationException agce) {
             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, agce.getMessage());
         }
     }
 
-    private void calibrateCo2OnDevice() {
+    private void updateConfiguration(Consumer<LocalConfiguration> action) {
         try {
-            apiController.calibrateCo2(getSerialNo());
+            LocalConfiguration config = new LocalConfiguration();
+            action.accept(config);
+            apiController.setConfig(config);
             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;
diff --git a/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/ConfigurationHelper.java b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/ConfigurationHelper.java
new file mode 100644 (file)
index 0000000..4aea8bf
--- /dev/null
@@ -0,0 +1,84 @@
+/**
+ * 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.LocalConfiguration;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.library.unit.Units;
+import org.openhab.core.thing.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 ConfigurationHelper {
+
+    public static Map<String, String> createProperties(LocalConfiguration configuration) {
+        Map<String, String> properties = new HashMap<>(4);
+
+        String model = configuration.model;
+        if (model != null) {
+            properties.put(Thing.PROPERTY_MODEL_ID, model);
+        }
+
+        return properties;
+    }
+
+    public static final String CHANNEL_POST_TO_CLOUD = "post-to-cloud";
+
+    public static Map<String, State> createStates(LocalConfiguration configuration) {
+        Map<String, State> states = new HashMap<>(11);
+
+        states.put(CHANNEL_COUNTRY_CODE, toStringType(configuration.country));
+        states.put(CHANNEL_PM_STANDARD, toStringType(configuration.pmStandard));
+        states.put(CHANNEL_ABC_DAYS, toQuantityType(configuration.abcDays, Units.DAY));
+        states.put(CHANNEL_TVOC_LEARNING_OFFSET, toQuantityType(configuration.tvocLearningOffset, Units.ONE));
+        states.put(CHANNEL_NOX_LEARNING_OFFSET, toQuantityType(configuration.noxLearningOffset, Units.ONE));
+        states.put(CHANNEL_MQTT_BROKER_URL, toStringType(configuration.mqttBrokerUrl));
+        states.put(CHANNEL_TEMPERATURE_UNIT, toStringType(configuration.temperatureUnit));
+        states.put(CHANNEL_CONFIGURATION_CONTROL, toStringType(configuration.configurationControl));
+        states.put(CHANNEL_LED_BAR_BRIGHTNESS, toQuantityType(configuration.ledBarBrightness, Units.ONE));
+        states.put(CHANNEL_DISPLAY_BRIGHTNESS, toQuantityType(configuration.displayBrightness, Units.ONE));
+        states.put(CHANNEL_POST_TO_CLOUD, toOnOffType(configuration.postDataToAirGradient));
+        states.put(CHANNEL_MODEL, toStringType(configuration.model));
+
+        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);
+    }
+
+    private static State toOnOffType(@Nullable Boolean value) {
+        return value == null ? UnDefType.NULL : OnOffType.from(value);
+    }
+}
diff --git a/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/DynamicChannelHelper.java b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/DynamicChannelHelper.java
new file mode 100644 (file)
index 0000000..4fbd5f2
--- /dev/null
@@ -0,0 +1,81 @@
+/**
+ * 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.ArrayList;
+import java.util.List;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.thing.Channel;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.binding.builder.ChannelBuilder;
+import org.openhab.core.thing.binding.builder.ThingBuilder;
+import org.openhab.core.thing.type.ChannelTypeUID;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link DynamicChannelHelper} is responsible for creating dynamic configuration channels.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ */
+@NonNullByDefault
+public class DynamicChannelHelper {
+
+    private record ConfigurationChannel(String id, String typeId, String itemType) {
+    }
+
+    private final static List<ConfigurationChannel> channels = new ArrayList<ConfigurationChannel>() {
+        {
+            add(new ConfigurationChannel(CHANNEL_COUNTRY_CODE, CHANNEL_COUNTRY_CODE, "String"));
+            add(new ConfigurationChannel(CHANNEL_PM_STANDARD, CHANNEL_PM_STANDARD, "String"));
+            add(new ConfigurationChannel(CHANNEL_ABC_DAYS, CHANNEL_ABC_DAYS, "Number"));
+            add(new ConfigurationChannel(CHANNEL_TVOC_LEARNING_OFFSET, CHANNEL_TVOC_LEARNING_OFFSET, "Number"));
+            add(new ConfigurationChannel(CHANNEL_NOX_LEARNING_OFFSET, CHANNEL_NOX_LEARNING_OFFSET, "Number"));
+            add(new ConfigurationChannel(CHANNEL_MQTT_BROKER_URL, CHANNEL_MQTT_BROKER_URL, "String"));
+            add(new ConfigurationChannel(CHANNEL_TEMPERATURE_UNIT, CHANNEL_TEMPERATURE_UNIT, "String"));
+            add(new ConfigurationChannel(CHANNEL_CONFIGURATION_CONTROL, CHANNEL_CONFIGURATION_CONTROL, "String"));
+            add(new ConfigurationChannel(CHANNEL_POST_TO_CLOUD, CHANNEL_POST_TO_CLOUD, "Switch"));
+            add(new ConfigurationChannel(CHANNEL_LED_BAR_BRIGHTNESS, CHANNEL_LED_BAR_BRIGHTNESS,
+                    "Number:Dimensionless"));
+            add(new ConfigurationChannel(CHANNEL_DISPLAY_BRIGHTNESS, CHANNEL_DISPLAY_BRIGHTNESS,
+                    "Number:Dimensionless"));
+            add(new ConfigurationChannel(CHANNEL_MODEL, CHANNEL_MODEL, "String"));
+            add(new ConfigurationChannel(CHANNEL_LED_BAR_TEST, CHANNEL_LED_BAR_TEST, "String"));
+        }
+    };
+
+    private final static Logger logger = LoggerFactory.getLogger(DynamicChannelHelper.class);
+
+    public static ThingBuilder updateThingWithConfigurationChannels(Thing thing, ThingBuilder builder) {
+        for (ConfigurationChannel channel : channels) {
+            addLocalConfigurationChannel(thing, builder, channel);
+        }
+
+        return builder;
+    }
+
+    private static void addLocalConfigurationChannel(Thing originalThing, ThingBuilder builder,
+            ConfigurationChannel toAdd) {
+        ChannelUID channelId = new ChannelUID(originalThing.getUID(), toAdd.id);
+        if (originalThing.getChannel(channelId) == null) {
+            logger.debug("Adding dynamic channel {} to {}", toAdd.id, originalThing.getUID());
+            ChannelTypeUID typeId = new ChannelTypeUID(BINDING_ID, toAdd.typeId);
+            Channel channel = ChannelBuilder.create(channelId, toAdd.itemType).withType(typeId).build();
+            builder.withChannel(channel);
+        }
+    }
+}
index 41e724e393752e86c872108fe30f88bbfe0cd6c9..9cfee9ccfa102e2118fa92bc68bdeed602da9f14 100644 (file)
@@ -66,13 +66,13 @@ public class MeasureHelper {
     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_ATMP, toQuantityType(measure.getTemperature(), 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));
+        states.put(CHANNEL_RHUM, toQuantityType(measure.getHumidity(), Units.PERCENT));
+        states.put(CHANNEL_UPLOADS_SINCE_BOOT, toQuantityType(measure.getBootCount(), Units.ONE));
 
         Double rco2 = measure.rco2;
         if (rco2 != null) {
diff --git a/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/model/LocalConfiguration.java b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/model/LocalConfiguration.java
new file mode 100644 (file)
index 0000000..aa6398e
--- /dev/null
@@ -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.model;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * Data model class for configuration from a local sensor.
+ *
+ * @author Jørgen Austvik - Initial contribution
+ */
+@NonNullByDefault
+public class LocalConfiguration {
+
+    @Nullable
+    public String country; // ALPHA-2 Country code
+
+    @Nullable
+    public String pmStandard; // usaqi/ugm3
+
+    @Nullable
+    public String ledBarMode; // off, pm, co2
+
+    @Nullable
+    public Long abcDays; // Co2 calibration automatic baseline calibration days ( 0-200)
+
+    @Nullable
+    public Long tvocLearningOffset; // Time constant of long-term estimator for offset. Past events will be forgotten
+                                    // after about twice the learning time. Range 1..1000 [hours]
+
+    @Nullable
+    public Long noxLearningOffset; // Time constant of long-term estimator for offset. Past events will be forgotten
+                                   // after about twice the learning time. Range 1..1000 [hours]
+
+    @Nullable
+    public String mqttBrokerUrl;
+
+    @Nullable
+    public String temperatureUnit; // c/f
+
+    @Nullable
+    public String configurationControl; // local, cloud, both
+
+    @Nullable
+    public Boolean postDataToAirGradient;
+
+    @Nullable
+    public Long ledBarBrightness; // 0 - 100
+
+    @Nullable
+    public Long displayBrightness; // 0 - 100
+
+    @Nullable
+    public Boolean offlineMode; // Don't connect to wifi
+
+    @Nullable
+    public String model;
+
+    @Nullable
+    public Boolean co2CalibrationRequested; // TRIGGER: Calibration of Co2 sensor
+
+    @Nullable
+    public Boolean ledBarTestRequested; // TRIGGER: LEDs will run test sequence
+}
index 726ca276c8076c82c0ad57f14307ca43a6729498..924a8ace0cd95f6dfc063320d512a51f9dcc1e85 100644 (file)
@@ -91,6 +91,30 @@ public class Measure {
         return null;
     }
 
+    public @Nullable Long getBootCount() {
+        if (bootCount == null) {
+            return boot;
+        }
+
+        return bootCount;
+    }
+
+    public @Nullable Double getTemperature() {
+        if (atmpCompensated == null) {
+            return atmp;
+        }
+
+        return atmpCompensated;
+    }
+
+    public @Nullable Double getHumidity() {
+        if (rhumCompensated == null) {
+            return rhum;
+        }
+
+        return rhumCompensated;
+    }
+
     @Nullable
     public String locationId;
 
@@ -115,9 +139,15 @@ public class Measure {
     @Nullable
     public Double atmp; // The ambient temperature in celsius
 
+    @Nullable
+    public Double atmpCompensated; // The ambient temperature, compensated for sensor inaccuracies
+
     @Nullable
     public Double rhum; // The relative humidity in percent
 
+    @Nullable
+    public Double rhumCompensated; // The relative humidity in percent, compensated for sensor inaccuracies
+
     @Nullable
     public Double rco2; // The CO2 value in ppm
 
@@ -127,9 +157,15 @@ public class Measure {
     @Nullable
     public Double tvocIndex; // The value of the TVOC index, sensor model dependent
 
+    @Nullable
+    public Double tvocRaw; // Raw data from TVOC senosor
+
     @Nullable
     public Double noxIndex; // The value of the NOx index, sensor model dependent
 
+    @Nullable
+    public Double noxRaw; // Raw data from NOx sensor
+
     @Nullable
     public Double wifi; // The wifi signal strength in dBm
 
@@ -157,6 +193,9 @@ public class Measure {
     @Nullable
     public Long boot; // Number of times sensor has uploaded data since last reboot
 
+    @Nullable
+    public Long bootCount; // Same as boot, in firmwares > v3
+
     @Nullable
     public String fwMode; // Model of sensor from local API
 
index b4557920723c5ca4f6bd50ac7b8a7f61c29385db..b1fd1adaa1476a673807e67b7e19cebd574d59ee 100644 (file)
                        <channel id="leds" typeId="leds-mode"/>
                        <channel id="calibration" typeId="calibration"/>
                        <channel id="uploads-since-boot" typeId="uploads-since-boot"/>
+                       <!-- These are added dynamically if the device supports them
+                               <channel id="country-code" typeId="country-code"/>
+                               <channel id="pm-standard" typeId="pm-standard"/>
+                               <channel id="abc-days" typeId="abc-days"/>
+                               <channel id="tvoc-learning-offset" typeId="tvoc-learning-offset"/>
+                               <channel id="nox-learning-offset" typeId="nox-learning-offset"/>
+                               <channel id="mqtt-broker-url" typeId="mqtt-broker-url"/>
+                               <channel id="temperature-unit" typeId="temperature-unit"/>
+                               <channel id="configuration-control" typeId="configuration-control"/>
+                               <channel id="post-to-cloud" typeId="post-to-cloud"/>
+                               <channel id="led-bar-brightness" typeId="led-bar-brightness"/>
+                               <channel id="display-brightness" typeId="display-brightness"/>
+                               <channel id="model" typeId="model"/>
+                               <channel id="led-bar-test" typeId="led-bar-test"/>
+                       -->
                </channels>
 
                <properties>
                </command>
        </channel-type>
 
+       <channel-type id="country-code">
+               <item-type>String</item-type>
+               <label>Country code</label>
+               <description>2 digit country code (ALPHA-2)</description>
+               <state readOnly="false"/>
+       </channel-type>
+
+       <channel-type id="pm-standard">
+               <item-type>String</item-type>
+               <label>Parts per Million Standard</label>
+               <description>Standard used for Parts per Million measurements</description>
+               <state readOnly="false">
+                       <options>
+                               <option value="us-aqi">USAqi</option>
+                               <option value="ugm3">ugm3</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="abc-days">
+               <item-type>Number</item-type>
+               <label>Automatic Baseline Calibration (Days)</label>
+               <description>Co2 calibration automatic baseline calibration days</description>
+               <state min="0" max="200" step="1" readOnly="false" pattern="%d %unit%"/>
+       </channel-type>
+
+       <channel-type id="tvoc-learning-offset">
+               <item-type>Number</item-type>
+               <label>TVOC learnings offset (hours)</label>
+               <description>Time constant of long-term estimator for offset. Past events will be forgotten after about twice the
+                       learning time.</description>
+               <state min="0" max="1000" step="1" readOnly="false" pattern="%d %unit%"/>
+       </channel-type>
+
+       <channel-type id="nox-learning-offset">
+               <item-type>Number</item-type>
+               <label>NOX learnings offset (hours)</label>
+               <description>Time constant of long-term estimator for offset. Past events will be forgotten after about twice the
+                       learning time.</description>
+               <state min="0" max="1000" step="1" readOnly="false" pattern="%d %unit%"/>
+       </channel-type>
+
+       <channel-type id="mqtt-broker-url">
+               <item-type>String</item-type>
+               <label>MQTT Broker URL</label>
+               <description>MQTT Broker URL</description>
+               <state readOnly="false"/>
+       </channel-type>
+
+       <channel-type id="temperature-unit">
+               <item-type>String</item-type>
+               <label>Temperature Unit</label>
+               <description>Temperature unit used on the display</description>
+               <state readOnly="false">
+                       <options>
+                               <option value="c">Celsius</option>
+                               <option value="f">Fahrenheit</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="configuration-control">
+               <item-type>String</item-type>
+               <label>Configuration Control</label>
+               <description>Where the unit is configured from</description>
+               <state readOnly="false">
+                       <options>
+                               <option value="both">Both</option>
+                               <option value="local">Local</option>
+                               <option value="cloud">Cloud</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="post-to-cloud">
+               <item-type>Switch</item-type>
+               <label>Send to cloud</label>
+               <description>Send data to the AirGradient cloud</description>
+               <state readOnly="false"/>
+       </channel-type>
+
+       <channel-type id="led-bar-brightness">
+               <item-type>Number:Dimensionless</item-type>
+               <label>Led bar brightness</label>
+               <description>Brightness of the LED bar.</description>
+               <state min="0" max="100" step="1" readOnly="false" pattern="%d"/>
+       </channel-type>
+
+       <channel-type id="display-brightness">
+               <item-type>Number:Dimensionless</item-type>
+               <label>Display brightness</label>
+               <description>Brightness of the display.</description>
+               <state min="0" max="100" step="1" readOnly="false" pattern="%d"/>
+       </channel-type>
+
+       <channel-type id="model">
+               <item-type>String</item-type>
+               <label>Model</label>
+               <description>Model of the device</description>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="led-bar-test">
+               <item-type>String</item-type>
+               <label>LED Bar test</label>
+               <description>Test LED bar</description>
+               <state readOnly="false"/>
+       </channel-type>
+
 </thing:thing-descriptions>
index d06878667192f501c8c664edc837ead5aa078311..2cd75f8ca30e1b98124c724dc40548681b139a1d 100644 (file)
@@ -58,6 +58,19 @@ public class AirGradientLocationHandlerTest {
         }
     };
 
+    private static final Measure TEST_MEASURE_V3_1_1 = new Measure() {
+        {
+            locationId = "12345";
+            locationName = "Location name";
+            timestamp = "2024-01-07T11:28:56.000Z";
+            serialno = "ecda3b1a2a50";
+            firmwareVersion = "3.1.1";
+            atmpCompensated = 24.2;
+            rhumCompensated = 36d;
+            bootCount = 16l;
+        }
+    };
+
     @Nullable
     private AirGradientLocationHandler sut;
 
@@ -102,4 +115,18 @@ public class AirGradientLocationHandlerTest {
         verify(callbackMock).stateUpdated(new ChannelUID(sut.getThing().getUID(), CHANNEL_TVOC),
                 new QuantityType<>("51 ppb"));
     }
+
+    // Firmware Version 3.1.1 has slight changes in the Json
+    @Test
+    public void testSetMeasureVersion3_1_1() {
+        sut.setCallback(callbackMock);
+        sut.setMeasurment(TEST_MEASURE_V3_1_1);
+
+        verify(callbackMock).stateUpdated(new ChannelUID(sut.getThing().getUID(), CHANNEL_ATMP),
+                new QuantityType<>("24.2 °C"));
+        verify(callbackMock).stateUpdated(new ChannelUID(sut.getThing().getUID(), CHANNEL_RHUM),
+                new QuantityType<>("36 %"));
+        verify(callbackMock).stateUpdated(new ChannelUID(sut.getThing().getUID(), CHANNEL_UPLOADS_SINCE_BOOT),
+                new QuantityType<>("16"));
+    }
 }
index f71bc1bed7db17573f5ac841908020b31376df01..727b1a0df78c502b0c2816ec43282335280d83cc 100644 (file)
@@ -49,6 +49,10 @@ public class RemoteApiControllerTest {
         }
     };
 
+    private static final String SINGLE_CONFIG = """
+             {"country":"NO","pmStandard":"ugm3","ledBarMode":"off","abcDays":8,"tvocLearningOffset":12,"noxLearningOffset":12,"mqttBrokerUrl":"https://192.168.1.1/mqtt","temperatureUnit":"c","configurationControl":"both","postDataToAirGradient":true,"ledBarBrightness":100,"displayBrightness":100,"offlineMode":false,"model":"I-9PSL"}
+            """;
+
     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}
             """;
@@ -259,4 +263,29 @@ public class RemoteApiControllerTest {
         assertThat(res.get(0).noxIndex, closeTo(1, 0.1));
         assertThat(res.get(0).serialno, is("4XXXXXXXXXXc"));
     }
+
+    @Test
+    public void testGetConfig() 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_CONFIG);
+
+        var res = sut.getConfig();
+        assertThat(res.abcDays, is(8L));
+        assertThat(res.configurationControl, is("both"));
+        assertThat(res.country, is("NO"));
+        assertThat(res.displayBrightness, is(100L));
+        assertThat(res.ledBarBrightness, is(100L));
+        assertThat(res.ledBarMode, is("off"));
+        assertThat(res.model, is("I-9PSL"));
+        assertThat(res.mqttBrokerUrl, is("https://192.168.1.1/mqtt"));
+        assertThat(res.noxLearningOffset, is(12L));
+        assertThat(res.pmStandard, is("ugm3"));
+        assertThat(res.postDataToAirGradient, is(true));
+        assertThat(res.temperatureUnit, is("c"));
+        assertThat(res.tvocLearningOffset, is(12L));
+    }
 }