diff --git a/bundles/org.openhab.binding.airquality/README.md b/bundles/org.openhab.binding.airquality/README.md index 0a21a20f445f4..8d54e36152304 100644 --- a/bundles/org.openhab.binding.airquality/README.md +++ b/bundles/org.openhab.binding.airquality/README.md @@ -11,26 +11,32 @@ To use this binding, you first need to [register and get your API token](https:/ ## Supported Things -There is exactly one supported thing type, which represents the air quality information for an observation location. -It has the `aqi` id. -Of course, you can add multiple Things, e.g. for measuring AQI for different locations. +Bridge: The binding supports a bridge to connect to the [AQIcn.org service](https://aqicn.org). A bridge uses the thing ID "api". + +Station: Represents the air quality information for an observation location. + +Of course, you can add multiple Stations, e.g. for measuring AQI for different locations. ## Discovery Local Air Quality can be autodiscovered based on system location. -You will have complete default configuration with your apiKey. +You will created a Bridge with your apiKey. -## Binding Configuration +## Bridge Configuration + +The bridge configuration only holds the api key : + +| Parameter | Description | +|-----------|-------------------------------------------------------------------------| +| apiKey | Data-platform token to access the AQIcn.org service. Mandatory. | -The binding has no configuration options, all configuration is done at Thing level. ## Thing Configuration -The thing has a few configuration parameters: +The 'Station' thing has a few configuration parameters: | Parameter | Description | |-----------|-------------------------------------------------------------------------| -| apikey | Data-platform token to access the AQIcn.org service. Mandatory. | | location | Geo coordinates to be considered by the service. | | stationId | Unique ID of the measuring station. | | refresh | Refresh interval in minutes. Optional, the default value is 60 minutes. | @@ -45,59 +51,75 @@ For the location parameter, the following syntax is allowed (comma separated lat If you always want to receive data from specific station and you know its unique ID, you can enter it instead of the coordinates. This `stationId` can be found by using the following link: -https://api.waqi.info/search/?token=TOKEN&keyword=NAME, replacing TOKEN by your apikey and NAME by the station you are looking for. +https://api.waqi.info/search/?token=TOKEN&keyword=NAME, replacing TOKEN by your apiKey and NAME by the station you are looking for. + +### Thing properties + +Once created, at first execution, the station's properties will be filled with informations gathered from the web service : + +- Nearest measuring station location +- Measuring station ID +- Latitude/longitude of measuring station + ## Channels -The AirQuality information that is retrieved is available as these channels: +The AirQuality information that is retrieved for a given is available as these channels: + +### AQI Channels Group - Global Results + +| Channel ID | Item Type | Description | +|-----------------|----------------------|----------------------------------------------| +| alert-level | Number | Alert level (*) associated to AQI Index. | +| index | Number | Air Quality Index | +| timestamp | DateTime | Observation date and time | +| dominent | String | Dominent Pollutant | +| icon | Image | Pictogram associated to alert-level | +| color | Color | Color associated to alert level. | + +### Weather Channels Group | Channel ID | Item Type | Description | |-----------------|----------------------|----------------------------------------------| -| aqiLevel | Number | Air Quality Index | -| aqiColor | Color | Color associated to given AQI Index. | -| aqiDescription | String | AQI Description | -| locationName | String | Nearest measuring station location | -| stationId | Number | Measuring station ID | -| stationLocation | Location | Latitude/longitude of measuring station | -| pm25 | Number | Fine particles pollution level (PM2.5) | -| pm10 | Number | Coarse dust particles pollution level (PM10) | -| o3 | Number | Ozone level (O3) | -| no2 | Number | Nitrogen Dioxide level (NO2) | -| co | Number | Carbon monoxide level (CO) | -| so2 | Number | Sulfur dioxide level (SO2) | -| observationTime | DateTime | Observation date and time | | temperature | Number:Temperature | Temperature in Celsius degrees | | pressure | Number:Pressure | Pressure level | | humidity | Number:Dimensionless | Humidity level | -| dominentpol | String | Dominent Polutor | +| dew-point | Number:Temperature | Dew point temperature | +| wind-speed | Number:Speed | Wind speed | + +### Pollutants Channels Group + +For each pollutant (PM25, PM10, O3, NO2, CO, SO2) , depending upon availability of the station, +you will be provided with the following informations + +| Channel ID | Item Type | Description | +|-----------------|----------------------|----------------------------------------------| +| value | Number:Density | Measured density of the pollutant | +| index | Number | AQI Index of the single pollutant | +| alert-level | Number | Alert level associate to the index | -`AQI Description` item provides a human-readable output that can be interpreted e.g. by MAP transformation. -*Note that channels like* `pm25`, `pm10`, `o3`, `no2`, `co`, `so2` *can sometimes return* `UNDEF` *value due to the fact that some stations don't provide measurements for them.* +(*) The alert level is described by a color : + +| Code | Color | Description | +|------|--------|--------------------------------| +| 0 | Green | Good | +| 1 | Yellow | Moderate | +| 2 | Orange | Unhealthy for Sensitive Groups | +| 3 | Red | Unhealthy | +| 4 | Purple | Very Unhealthy | +| 5 | Maroon | Hazardous | + ## Full Example -airquality.map: - -```text --=- -UNDEF=No data -NULL=No data -NO_DATA=No data -GOOD=Good -MODERATE=Moderate -UNHEALTHY_FOR_SENSITIVE=Unhealthy for sensitive groups -UNHEALTHY=Unhealthy -VERY_UNHEALTHY=Very unhealthy -HAZARDOUS=Hazardous -``` airquality.things: ```java -airquality:aqi:home "AirQuality" @ "Krakow" [ apikey="XXXXXXXXXXXX", location="50.06465,19.94498", refresh=60 ] -airquality:aqi:warsaw "AirQuality in Warsaw" [ apikey="XXXXXXXXXXXX", location="52.22,21.01", refresh=60 ] -airquality:aqi:brisbane "AirQuality in Brisbane" [ apikey="XXXXXXXXXXXX", stationId=5115 ] +Bridge airquality:api:main "Bridge" [apiKey="xxxyyyzzz"] { + station MyHouse "Krakow"[location="50.06465,19.94498", refresh=60] +} ``` airquality.items: @@ -105,24 +127,19 @@ airquality.items: ```java Group AirQuality -Number Aqi_Level "Air Quality Index" (AirQuality) { channel="airquality:aqi:home:aqiLevel" } -String Aqi_Description "AQI Level [MAP(airquality.map):%s]" (AirQuality) { channel="airquality:aqi:home:aqiDescription" } - -Number Aqi_Pm25 "PM\u2082\u2085 Level" (AirQuality) { channel="airquality:aqi:home:pm25" } -Number Aqi_Pm10 "PM\u2081\u2080 Level" (AirQuality) { channel="airquality:aqi:home:pm10" } -Number Aqi_O3 "O\u2083 Level" (AirQuality) { channel="airquality:aqi:home:o3" } -Number Aqi_No2 "NO\u2082 Level" (AirQuality) { channel="airquality:aqi:home:no2" } -Number Aqi_Co "CO Level" (AirQuality) { channel="airquality:aqi:home:co" } -Number Aqi_So2 "SO\u2082 Level" (AirQuality) { channel="airquality:aqi:home:so2" } +Number Aqi_Level "Air Quality Index" (AirQuality) { channel="airquality:station:local:aqi#index" } +Number Aqi_Pm25 "PM\u2082\u2085 Level" (AirQuality) { channel="airquality:station:local:pm25#value" } +Number Aqi_Pm10 "PM\u2081\u2080 Level" (AirQuality) { channel="airquality:station:local:pm10#value" } +Number Aqi_O3 "O\u2083 Level" (AirQuality) { channel="airquality:station:local:o3#value" } +Number Aqi_No2 "NO\u2082 Level" (AirQuality) { channel="airquality:station:local:no2#value" } +Number Aqi_Co "CO Level" (AirQuality) { channel="airquality:station:local:co#value" } +Number Aqi_So2 "SO\u2082 Level" (AirQuality) { channel="airquality:station:local:so2#value" } -String Aqi_LocationName "Measuring Location" (AirQuality) { channel="airquality:aqi:home:locationName" } -Location Aqi_StationGeo "Station Location" (AirQuality) { channel="airquality:aqi:home:stationLocation" } -Number Aqi_StationId "Station ID" (AirQuality) { channel="airquality:aqi:home:stationId" } -DateTime Aqi_ObservationTime "Time of observation [%1$tH:%1$tM]" (AirQuality) { channel="airquality:aqi:home:observationTime" } +DateTime Aqi_ObservationTime "Time of observation [%1$tH:%1$tM]" (AirQuality) { channel="airquality:station:local:aqi#timestamp" } -Number:Temperature Aqi_Temperature "Temperature" (AirQuality) { channel="airquality:aqi:home:temperature" } -Number:Pressure Aqi_Pressure "Pressure" (AirQuality) { channel="airquality:aqi:home:pressure" } -Number:Dimensionless Aqi_Humidity "Humidity" (AirQuality) { channel="airquality:aqi:home:humidity" } +Number:Temperature Aqi_Temperature "Temperature" (AirQuality) { channel="airquality:station:local:weather#temperature" } +Number:Pressure Aqi_Pressure "Pressure" (AirQuality) { channel="airquality:station:local:weather#pressure" } +Number:Dimensionless Aqi_Humidity "Humidity" (AirQuality) { channel="airquality:station:localweather#humidity" } ``` airquality.sitemap: @@ -143,7 +160,7 @@ sitemap airquality label="Air Quality" { Aqi_Description=="HAZARDOUS"="#7e0023", =="VERY_UNHEALTHY"="#660099", =="UNHEALTHY"="#cc0033", - =="UNHEALTHY_FOR_SENSITIVE"="#ff9933", + =="UNHEALTHY_FSG"="#ff9933", =="MODERATE"="#ffde33", =="GOOD"="#009966" ] @@ -159,16 +176,12 @@ sitemap airquality label="Air Quality" { } Frame { - Text item=Aqi_LocationName Text item=Aqi_ObservationTime Text item=Aqi_Temperature Text item=Aqi_Pressure Text item=Aqi_Humidity } - Frame label="Station Location" { - Mapview item=Aqi_StationGeo height=10 - } } ``` @@ -189,7 +202,7 @@ then hsb = "280,100,60" case "UNHEALTHY": hsb = "345,100,80" - case "UNHEALTHY_FOR_SENSITIVE": + case "UNHEALTHY_FSG": hsb = "30,80,100" case "MODERATE": hsb = "50,80,100" diff --git a/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/AirQualityBindingConstants.java b/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/AirQualityBindingConstants.java index 0bb6f84476aee..d738cf83a05b9 100644 --- a/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/AirQualityBindingConstants.java +++ b/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/AirQualityBindingConstants.java @@ -12,74 +12,44 @@ */ package org.openhab.binding.airquality.internal; -import static org.openhab.core.library.unit.MetricPrefix.HECTO; - -import java.util.Collections; -import java.util.Set; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import javax.measure.Unit; -import javax.measure.quantity.Dimensionless; -import javax.measure.quantity.Pressure; -import javax.measure.quantity.Temperature; - import org.eclipse.jdt.annotation.NonNullByDefault; -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.ThingTypeUID; -import org.openhab.core.types.State; /** * The {@link AirQualityBinding} class defines common constants, which are * used across the whole binding. * - * @author Kuba Wolanin - Initial contribution - * @author Łukasz Dywicki - Initial contribution + * @author Gaël L'hopital - Initial contribution */ @NonNullByDefault public class AirQualityBindingConstants { - public static final String BINDING_ID = "airquality"; + private static final String BINDING_ID = "airquality"; public static final String LOCAL = "local"; - // List of all Thing Type UIDs - public static final ThingTypeUID THING_TYPE_AQI = new ThingTypeUID(BINDING_ID, "aqi"); + // List of thing properties + public static final String ATTRIBUTIONS = "Attributions"; + public static final String DISTANCE = "Distance"; + + // List of all Channel groups id's + public static final String AQI = "aqi"; + public static final String SENSITIVE = "sensitive-group"; // List of all Channel id's - public static final String AQI = "aqiLevel"; - public static final String AQI_COLOR = "aqiColor"; - public static final String AQIDESCRIPTION = "aqiDescription"; - public static final String PM25 = "pm25"; - public static final String PM10 = "pm10"; - public static final String O3 = "o3"; - public static final String NO2 = "no2"; - public static final String CO = "co"; - public static final String SO2 = "so2"; - public static final String LOCATIONNAME = "locationName"; - public static final String STATIONLOCATION = "stationLocation"; - public static final String STATIONID = "stationId"; - public static final String OBSERVATIONTIME = "observationTime"; + public static final String INDEX = "index"; + public static final String VALUE = "value"; + public static final String ALERT_LEVEL = "alert-level"; public static final String TEMPERATURE = "temperature"; public static final String PRESSURE = "pressure"; public static final String HUMIDITY = "humidity"; - public static final String DOMINENTPOL = "dominentpol"; - - public static final State GOOD = new StringType("GOOD"); - public static final State MODERATE = new StringType("MODERATE"); - public static final State UNHEALTHY_FOR_SENSITIVE = new StringType("UNHEALTHY_FOR_SENSITIVE"); - public static final State UNHEALTHY = new StringType("UNHEALTHY"); - public static final State VERY_UNHEALTHY = new StringType("VERY_UNHEALTHY"); - public static final State HAZARDOUS = new StringType("HAZARDOUS"); - - public static final Set SUPPORTED_THING_TYPES = Collections.singleton(THING_TYPE_AQI); - public static final Set SUPPORTED_CHANNEL_IDS = Stream.of(AQI, AQIDESCRIPTION, PM25, PM10, O3, NO2, CO, SO2, - LOCATIONNAME, STATIONLOCATION, STATIONID, OBSERVATIONTIME, TEMPERATURE, PRESSURE, HUMIDITY) - .collect(Collectors.toSet()); - - // Units of measurement of the data delivered by the API - public static final Unit API_TEMPERATURE_UNIT = SIUnits.CELSIUS; - public static final Unit API_HUMIDITY_UNIT = Units.PERCENT; - public static final Unit API_PRESSURE_UNIT = HECTO(SIUnits.PASCAL); + public static final String DEW_POINT = "dew-point"; + public static final String WIND_SPEED = "wind-speed"; + public static final String TIMESTAMP = "timestamp"; + public static final String DOMINENT = "dominent"; + public static final String ICON = "icon"; + public static final String COLOR = "color"; + + // Thing Type UIDs + public static final ThingTypeUID THING_TYPE_STATION = new ThingTypeUID(BINDING_ID, "station"); + public static final ThingTypeUID BRIDGE_TYPE_API = new ThingTypeUID(BINDING_ID, "api"); } diff --git a/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/AirQualityException.java b/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/AirQualityException.java new file mode 100644 index 0000000000000..57be6ca59953d --- /dev/null +++ b/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/AirQualityException.java @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.airquality.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * An exception that occurred while operating the binding + * + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +public class AirQualityException extends Exception { + private static final long serialVersionUID = -3398100220952729815L; + private int statusCode = -1; + + public AirQualityException(String message, Exception e) { + super(message, e); + } + + public AirQualityException(String message) { + super(message); + } + + public int getStatusCode() { + return statusCode; + } + + @Override + public @Nullable String getMessage() { + String message = super.getMessage(); + return message == null ? null + : String.format("Rest call failed: statusCode=%d, message=%s", statusCode, message); + } +} diff --git a/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/AirQualityHandlerFactory.java b/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/AirQualityHandlerFactory.java index b9bfa09ff480f..8b54f9b0ee118 100644 --- a/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/AirQualityHandlerFactory.java +++ b/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/AirQualityHandlerFactory.java @@ -14,10 +14,15 @@ import static org.openhab.binding.airquality.internal.AirQualityBindingConstants.*; +import java.util.Set; + import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; -import org.openhab.binding.airquality.internal.handler.AirQualityHandler; +import org.openhab.binding.airquality.internal.handler.AirQualityBridgeHandler; +import org.openhab.binding.airquality.internal.handler.AirQualityStationHandler; +import org.openhab.core.i18n.LocationProvider; import org.openhab.core.i18n.TimeZoneProvider; +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; @@ -27,23 +32,25 @@ import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Reference; -import com.google.gson.Gson; - /** - * The {@link AirQualityHandlerFactory} is responsible for creating things and thing - * handlers. + * The {@link AirQualityHandlerFactory} is responsible for creating thing and thing + * handler. * - * @author Kuba Wolanin - Initial contribution + * @author Gaël L'hopital - Initial contribution */ @Component(service = ThingHandlerFactory.class, configurationPid = "binding.airquality") @NonNullByDefault public class AirQualityHandlerFactory extends BaseThingHandlerFactory { - private final Gson gson = new Gson(); + private static final Set SUPPORTED_THING_TYPES = Set.of(BRIDGE_TYPE_API, THING_TYPE_STATION); + private final TimeZoneProvider timeZoneProvider; + private final LocationProvider locationProvider; @Activate - public AirQualityHandlerFactory(final @Reference TimeZoneProvider timeZoneProvider) { + public AirQualityHandlerFactory(final @Reference TimeZoneProvider timeZoneProvider, + final @Reference LocationProvider locationProvider) { this.timeZoneProvider = timeZoneProvider; + this.locationProvider = locationProvider; } @Override @@ -55,10 +62,9 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) { protected @Nullable ThingHandler createHandler(Thing thing) { ThingTypeUID thingTypeUID = thing.getThingTypeUID(); - if (THING_TYPE_AQI.equals(thingTypeUID)) { - return new AirQualityHandler(thing, gson, timeZoneProvider); - } - - return null; + return THING_TYPE_STATION.equals(thingTypeUID) + ? new AirQualityStationHandler(thing, timeZoneProvider, locationProvider) + : BRIDGE_TYPE_API.equals(thingTypeUID) ? new AirQualityBridgeHandler((Bridge) thing, locationProvider) + : null; } } diff --git a/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/api/ApiBridge.java b/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/api/ApiBridge.java new file mode 100644 index 0000000000000..413a18b7aed98 --- /dev/null +++ b/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/api/ApiBridge.java @@ -0,0 +1,88 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.airquality.internal.api; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.airquality.internal.AirQualityException; +import org.openhab.binding.airquality.internal.api.dto.AirQualityData; +import org.openhab.binding.airquality.internal.api.dto.AirQualityResponse; +import org.openhab.binding.airquality.internal.api.dto.AirQualityResponse.ResponseStatus; +import org.openhab.core.io.net.http.HttpUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; + +/** + * The {@link ApiBridge} is the interface between handlers + * and the actual web service + * + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +public class ApiBridge { + private static final Gson GSON = new Gson(); + private static final String URL = "http://api.waqi.info/feed/%query%/?token=%apiKey%"; + private static final int REQUEST_TIMEOUT_MS = (int) TimeUnit.SECONDS.toMillis(30); + + private final Logger logger = LoggerFactory.getLogger(ApiBridge.class); + private final String apiKey; + + public ApiBridge(String apiKey) { + this.apiKey = apiKey; + } + + /** + * Build request URL from configuration data + * + * @return a valid URL for the aqicn.org service + * @throws AirQualityException + */ + private String buildRequestURL(String key, int stationId, String location) { + String geoStr = stationId != 0 ? String.format("@%d", stationId) + : String.format("geo:%s", + location.replace(" ", "").replace(",", ";").replace("\"", "").replace("'", "").trim()); + + return URL.replace("%apiKey%", key).replace("%query%", geoStr); + } + + /** + * Request new air quality data to the aqicn.org service + * + * @return an air quality data object mapping the JSON response + * @throws AirQualityException + */ + public AirQualityData getData(int stationId, String location, int retryCounter) throws AirQualityException { + String urlStr = buildRequestURL(apiKey, stationId, location); + logger.debug("URL = {}", urlStr); + + try { + String response = HttpUtil.executeUrl("GET", urlStr, null, null, null, REQUEST_TIMEOUT_MS); + logger.debug("aqiResponse = {}", response); + AirQualityResponse result = GSON.fromJson(response, AirQualityResponse.class); + if (result != null && result.getStatus() == ResponseStatus.OK) { + return result.getData(); + } else if (retryCounter == 0) { + logger.debug("Error in aqicn.org, retrying once"); + return getData(stationId, location, retryCounter + 1); + } + throw new AirQualityException("Error in aqicn.org response: Missing data sub-object"); + } catch (IOException | JsonSyntaxException e) { + throw new AirQualityException("Communication error", e); + } + } +} diff --git a/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/api/Appreciation.java b/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/api/Appreciation.java new file mode 100644 index 0000000000000..f80c71207e03f --- /dev/null +++ b/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/api/Appreciation.java @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.airquality.internal.api; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.library.types.HSBType; +import org.openhab.core.types.State; + +/** + * The {@link Appreciation} enum lists all possible appreciation + * of the AQI Level associated with their standard color. + * + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +public enum Appreciation { + GOOD(HSBType.fromRGB(0, 228, 0)), + MODERATE(HSBType.fromRGB(255, 255, 0)), + UNHEALTHY_FSG(HSBType.fromRGB(255, 126, 0)), + UNHEALTHY(HSBType.fromRGB(255, 0, 0)), + VERY_UNHEALTHY(HSBType.fromRGB(143, 63, 151)), + HAZARDOUS(HSBType.fromRGB(126, 0, 35)); + + private HSBType color; + + Appreciation(HSBType color) { + this.color = color; + } + + public State getColor() { + return color; + } +} diff --git a/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/api/ConcentrationRange.java b/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/api/ConcentrationRange.java new file mode 100644 index 0000000000000..e40e42f556c47 --- /dev/null +++ b/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/api/ConcentrationRange.java @@ -0,0 +1,45 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.airquality.internal.api; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link ConcentrationRange} is responsible to store the range of + * a given physical measure associated with the corresponding AQI + * index. + * + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +public class ConcentrationRange { + private final double min; + private final double span; + private final Index index; + + ConcentrationRange(double min, double max, Index index) { + this.min = min; + this.span = max - min; + this.index = index; + } + + /* + * Computes the concentration corresponding to the index + * if contained in the range + * + * @return : a physical concentration or -1 if not in range + */ + double getConcentration(double idx) { + return index.contains(idx) ? span / index.getSpan() * (idx - index.getMin()) + min : -1; + } +} diff --git a/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/api/Index.java b/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/api/Index.java new file mode 100644 index 0000000000000..09b7ed65c5646 --- /dev/null +++ b/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/api/Index.java @@ -0,0 +1,65 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.airquality.internal.api; + +import java.util.stream.Stream; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * The {@link Index} enum lists standard ranges of AQI indices + * along with their appreciation category. + * + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +public enum Index { + ZERO(0, 50, Appreciation.GOOD), + FIFTY(51, 100, Appreciation.MODERATE), + ONE_HUNDRED(101, 150, Appreciation.UNHEALTHY_FSG), + ONE_HUNDRED_FIFTY(151, 200, Appreciation.UNHEALTHY), + TWO_HUNDRED(201, 300, Appreciation.VERY_UNHEALTHY), + THREE_HUNDRED(301, 400, Appreciation.HAZARDOUS), + FOUR_HUNDRED(401, 500, Appreciation.HAZARDOUS); + + private double min; + private double max; + private Appreciation category; + + Index(double min, double max, Appreciation category) { + this.min = min; + this.max = max; + this.category = category; + } + + public double getMin() { + return min; + } + + public double getSpan() { + return max - min; + } + + boolean contains(double idx) { + return min <= idx && idx <= max; + } + + public static @Nullable Index find(double idx) { + return Stream.of(Index.values()).filter(i -> i.contains(idx)).findFirst().orElse(null); + } + + public Appreciation getCategory() { + return category; + } +} diff --git a/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/api/Pollutant.java b/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/api/Pollutant.java new file mode 100644 index 0000000000000..e82c971dddf6d --- /dev/null +++ b/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/api/Pollutant.java @@ -0,0 +1,96 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.airquality.internal.api; + +import static org.openhab.binding.airquality.internal.api.Index.*; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.Set; + +import javax.measure.Unit; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.library.unit.Units; +import org.openhab.core.types.State; +import org.openhab.core.types.UnDefType; + +/** + * The {@link Pollutant} enum lists all measures + * of the AQI Level associated with their standard color. + * + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +public enum Pollutant { + PM25(Units.MICROGRAM_PER_CUBICMETRE, 1, + Set.of(SensitiveGroup.RESPIRATORY, SensitiveGroup.HEART, SensitiveGroup.ELDERLY, SensitiveGroup.CHILDREN), + new ConcentrationRange(0, 12, ZERO), new ConcentrationRange(12.1, 35.4, FIFTY), + new ConcentrationRange(35.5, 55.4, ONE_HUNDRED), new ConcentrationRange(55.5, 150.4, ONE_HUNDRED_FIFTY), + new ConcentrationRange(150.5, 250.4, TWO_HUNDRED), new ConcentrationRange(250.5, 350.4, THREE_HUNDRED), + new ConcentrationRange(350.5, 500.4, FOUR_HUNDRED)), + PM10(Units.MICROGRAM_PER_CUBICMETRE, 0, Set.of(SensitiveGroup.RESPIRATORY), new ConcentrationRange(0, 54, ZERO), + new ConcentrationRange(55, 154, FIFTY), new ConcentrationRange(155, 254, ONE_HUNDRED), + new ConcentrationRange(255, 354, ONE_HUNDRED_FIFTY), new ConcentrationRange(355, 424, TWO_HUNDRED), + new ConcentrationRange(425, 504, THREE_HUNDRED), new ConcentrationRange(505, 604, FOUR_HUNDRED)), + NO2(Units.PARTS_PER_BILLION, 0, + Set.of(SensitiveGroup.ASTHMA, SensitiveGroup.RESPIRATORY, SensitiveGroup.ELDERLY, SensitiveGroup.CHILDREN), + new ConcentrationRange(0, 53, ZERO), new ConcentrationRange(54, 100, FIFTY), + new ConcentrationRange(101, 360, ONE_HUNDRED), new ConcentrationRange(361, 649, ONE_HUNDRED_FIFTY), + new ConcentrationRange(650, 1249, TWO_HUNDRED), new ConcentrationRange(1250, 1649, THREE_HUNDRED), + new ConcentrationRange(1650, 2049, FOUR_HUNDRED)), + SO2(Units.PARTS_PER_BILLION, 0, Set.of(SensitiveGroup.ASTHMA), new ConcentrationRange(0, 35, ZERO), + new ConcentrationRange(36, 75, FIFTY), new ConcentrationRange(76, 185, ONE_HUNDRED), + new ConcentrationRange(186, 304, ONE_HUNDRED_FIFTY), new ConcentrationRange(305, 604, TWO_HUNDRED), + new ConcentrationRange(605, 804, THREE_HUNDRED), new ConcentrationRange(805, 1004, FOUR_HUNDRED)), + CO(Units.PARTS_PER_BILLION, 1, Set.of(SensitiveGroup.HEART), new ConcentrationRange(0, 4.4, ZERO), + new ConcentrationRange(4.5, 9.4, FIFTY), new ConcentrationRange(9.5, 12.4, ONE_HUNDRED), + new ConcentrationRange(12.5, 15.4, ONE_HUNDRED_FIFTY), new ConcentrationRange(15.5, 30.4, TWO_HUNDRED), + new ConcentrationRange(30.5, 40.4, THREE_HUNDRED), new ConcentrationRange(40.5, 50.4, FOUR_HUNDRED)), + O3(Units.PARTS_PER_BILLION, 3, Set.of(SensitiveGroup.CHILDREN, SensitiveGroup.ASTHMA), + new ConcentrationRange(0, 54, ZERO), new ConcentrationRange(55, 124, FIFTY), + new ConcentrationRange(125, 164, ONE_HUNDRED), new ConcentrationRange(165, 204, ONE_HUNDRED_FIFTY), + new ConcentrationRange(205, 404, TWO_HUNDRED), new ConcentrationRange(405, 504, THREE_HUNDRED), + new ConcentrationRange(505, 604, FOUR_HUNDRED)); + + public static enum SensitiveGroup { + RESPIRATORY, + HEART, + ELDERLY, + CHILDREN, + ASTHMA; + } + + public final Set sensitiveGroups; + private final Unit unit; + private final Set breakpoints; + private final int scale; + + Pollutant(Unit unit, int scale, Set groups, ConcentrationRange... concentrations) { + this.sensitiveGroups = groups; + this.unit = unit; + this.breakpoints = Set.of(concentrations); + this.scale = scale; + } + + public State toQuantity(double idx) { + for (ConcentrationRange concentration : breakpoints) { + double equivalent = concentration.getConcentration(idx); + if (equivalent != -1) { + return new QuantityType<>(BigDecimal.valueOf(equivalent).setScale(scale, RoundingMode.HALF_UP), unit); + } + } + return UnDefType.UNDEF; + } +} diff --git a/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/json/AirQualityJsonCity.java b/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/api/dto/AirQualityCity.java similarity index 81% rename from bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/json/AirQualityJsonCity.java rename to bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/api/dto/AirQualityCity.java index d4455479a72f0..9789933735456 100644 --- a/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/json/AirQualityJsonCity.java +++ b/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/api/dto/AirQualityCity.java @@ -10,9 +10,8 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.airquality.internal.json; +package org.openhab.binding.airquality.internal.api.dto; -import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; @@ -20,17 +19,16 @@ import org.eclipse.jdt.annotation.Nullable; /** - * The {@link AirQualityJsonCity} is responsible for storing + * The {@link AirQualityCity} is responsible for storing * the "city" node from the waqi.org JSON response * * @author Kuba Wolanin - Initial contribution */ @NonNullByDefault -public class AirQualityJsonCity { - +public class AirQualityCity { private String name = ""; private @Nullable String url; - private List geo = new ArrayList<>(); + private List geo = List.of(); public String getName() { return name; diff --git a/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/json/AirQualityJsonData.java b/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/api/dto/AirQualityData.java similarity index 65% rename from bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/json/AirQualityJsonData.java rename to bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/api/dto/AirQualityData.java index 6ebd8890d8ee8..49998a7128853 100644 --- a/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/json/AirQualityJsonData.java +++ b/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/api/dto/AirQualityData.java @@ -10,32 +10,30 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.airquality.internal.json; +package org.openhab.binding.airquality.internal.api.dto; -import java.util.ArrayList; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.airquality.internal.api.Pollutant; /** - * The {@link AirQualityJsonData} is responsible for storing + * The {@link AirQualityData} is responsible for storing * the "data" node from the waqi.org JSON response * * @author Kuba Wolanin - Initial contribution */ @NonNullByDefault -public class AirQualityJsonData { - +public class AirQualityData { private int aqi; private int idx; - private @NonNullByDefault({}) AirQualityJsonTime time; - private @NonNullByDefault({}) AirQualityJsonCity city; - private List attributions = new ArrayList<>(); - private Map iaqi = new HashMap<>(); + private @NonNullByDefault({}) AirQualityTime time; + private @NonNullByDefault({}) AirQualityCity city; + private List attributions = List.of(); + private Map iaqi = Map.of(); private String dominentpol = ""; /** @@ -61,7 +59,7 @@ public int getStationId() { * * @return {AirQualityJsonTime} */ - public AirQualityJsonTime getTime() { + public AirQualityTime getTime() { return time; } @@ -70,20 +68,18 @@ public AirQualityJsonTime getTime() { * * @return {AirQualityJsonCity} */ - public AirQualityJsonCity getCity() { + public AirQualityCity getCity() { return city; } /** * Collects a list of attributions (vendors making data available) * and transforms it into readable string. - * Currently displayed in Thing Status description when ONLINE * * @return {String} */ public String getAttributions() { - String attributionsString = attributions.stream().map(Attribute::getName).collect(Collectors.joining(", ")); - return "Attributions : " + attributionsString; + return attributions.stream().map(Attribution::getName).collect(Collectors.joining(", ")); } public String getDominentPol() { @@ -92,9 +88,10 @@ public String getDominentPol() { public double getIaqiValue(String key) { AirQualityValue result = iaqi.get(key); - if (result != null) { - return result.getValue(); - } - return -1; + return result != null ? result.getValue() : -1; + } + + public double getIaqiValue(Pollutant pollutant) { + return getIaqiValue(pollutant.name().toLowerCase()); } } diff --git a/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/json/AirQualityJsonResponse.java b/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/api/dto/AirQualityResponse.java similarity index 75% rename from bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/json/AirQualityJsonResponse.java rename to bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/api/dto/AirQualityResponse.java index 3fec19c5217b7..9a00008f854a1 100644 --- a/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/json/AirQualityJsonResponse.java +++ b/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/api/dto/AirQualityResponse.java @@ -10,20 +10,20 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.airquality.internal.json; +package org.openhab.binding.airquality.internal.api.dto; import org.eclipse.jdt.annotation.NonNullByDefault; import com.google.gson.annotations.SerializedName; /** - * The {@link AirQualityJsonResponse} is the Java class used to map the JSON + * The {@link AirQualityResponse} is the Java class used to map the JSON * response to the aqicn.org request. * * @author Kuba Wolanin - Initial contribution */ @NonNullByDefault -public class AirQualityJsonResponse { +public class AirQualityResponse { public static enum ResponseStatus { NONE, @@ -34,15 +34,13 @@ public static enum ResponseStatus { } private ResponseStatus status = ResponseStatus.NONE; - - @SerializedName("data") - private @NonNullByDefault({}) AirQualityJsonData data; + private @NonNullByDefault({}) AirQualityData data; public ResponseStatus getStatus() { return status; } - public AirQualityJsonData getData() { + public AirQualityData getData() { return data; } } diff --git a/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/json/AirQualityJsonTime.java b/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/api/dto/AirQualityTime.java similarity index 63% rename from bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/json/AirQualityJsonTime.java rename to bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/api/dto/AirQualityTime.java index d2faa6199b49c..4a455412deec0 100644 --- a/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/json/AirQualityJsonTime.java +++ b/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/api/dto/AirQualityTime.java @@ -10,32 +10,22 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.airquality.internal.json; +package org.openhab.binding.airquality.internal.api.dto; import java.time.ZonedDateTime; import java.time.format.DateTimeParseException; import org.eclipse.jdt.annotation.NonNullByDefault; -import com.google.gson.annotations.SerializedName; - /** - * The {@link AirQualityJsonTime} is responsible for storing + * The {@link AirQualityTime} is responsible for storing * the "time" node from the waqi.org JSON response * - * @author Kuba Wolanin - Initial contribution - * @author Gaël L'hopital - Use ZonedDateTime instead of Calendar + * @author Gaël L'hopital - Initial contribution */ @NonNullByDefault -public class AirQualityJsonTime { - - @SerializedName("s") - private String dateString = ""; - - @SerializedName("tz") - private String timeZone = ""; - - private String iso = ""; +public class AirQualityTime { + private String iso = ""; // ISO representation of the timestamp, including TZ /** * Get observation time diff --git a/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/json/AirQualityValue.java b/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/api/dto/AirQualityValue.java similarity index 74% rename from bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/json/AirQualityValue.java rename to bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/api/dto/AirQualityValue.java index df72ea85d88f0..bddfb58bc8429 100644 --- a/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/json/AirQualityValue.java +++ b/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/api/dto/AirQualityValue.java @@ -10,24 +10,20 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.airquality.internal.json; +package org.openhab.binding.airquality.internal.api.dto; import org.eclipse.jdt.annotation.NonNullByDefault; -import com.google.gson.annotations.SerializedName; - /** * Wrapper type around values reported by aqicn index values. * * @author Łukasz Dywicki - Initial contribution */ @NonNullByDefault -public class AirQualityValue { - - @SerializedName("v") - private double value; +class AirQualityValue { + private double v; public double getValue() { - return value; + return v; } } diff --git a/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/json/Attribute.java b/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/api/dto/Attribution.java similarity index 91% rename from bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/json/Attribute.java rename to bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/api/dto/Attribution.java index 4db98ccb7ac00..85d49f7379241 100644 --- a/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/json/Attribute.java +++ b/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/api/dto/Attribution.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.airquality.internal.json; +package org.openhab.binding.airquality.internal.api.dto; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; @@ -21,8 +21,7 @@ * @author Łukasz Dywicki - Initial contribution */ @NonNullByDefault -public class Attribute { - +class Attribution { private @NonNullByDefault({}) String name; private @Nullable String url; private @Nullable String logo; diff --git a/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/AirQualityConfiguration.java b/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/config/AirQualityConfiguration.java similarity index 56% rename from bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/AirQualityConfiguration.java rename to bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/config/AirQualityConfiguration.java index 61efbc56c5285..dbad96a7b2c26 100644 --- a/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/AirQualityConfiguration.java +++ b/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/config/AirQualityConfiguration.java @@ -10,10 +10,10 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.airquality.internal; +package org.openhab.binding.airquality.internal.config; import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.airquality.internal.AirQualityException; /** * The {@link AirQualityConfiguration} is the class used to match the @@ -23,11 +23,19 @@ */ @NonNullByDefault public class AirQualityConfiguration { - public static final String LOCATION = "location"; + public static final String STATION_ID = "stationId"; - public String apikey = ""; public String location = ""; - public @Nullable Integer stationId; + public int stationId = 0; public int refresh = 60; + + public void checkValid() throws AirQualityException { + if (location.trim().isEmpty() && stationId == 0) { + throw new AirQualityException("Either 'location' or 'stationId' is mandatory and must be configured"); + } + if (refresh < 30) { + throw new AirQualityException("Parameter 'refresh' must be at least 30 minutes"); + } + } } diff --git a/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/config/SensitiveGroupConfiguration.java b/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/config/SensitiveGroupConfiguration.java new file mode 100644 index 0000000000000..09e376cea9d07 --- /dev/null +++ b/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/config/SensitiveGroupConfiguration.java @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.airquality.internal.config; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.airquality.internal.api.Pollutant.SensitiveGroup; + +/** + * The {@link SensitiveGroupConfiguration} is the class used to match the + * sensitive-group channel configuration. + * + * @author Gaël L"hopital - Initial contribution + */ +@NonNullByDefault +public class SensitiveGroupConfiguration { + private String group = "RESPIRATORY"; + + public @Nullable SensitiveGroup asSensitiveGroup() { + try { + SensitiveGroup value = SensitiveGroup.valueOf(group); + return value; + } catch (IllegalArgumentException e) { + return null; + } + } +} diff --git a/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/discovery/AirQualityDiscoveryService.java b/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/discovery/AirQualityDiscoveryService.java index 0da240541e105..85cef9c804871 100644 --- a/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/discovery/AirQualityDiscoveryService.java +++ b/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/discovery/AirQualityDiscoveryService.java @@ -13,26 +13,24 @@ package org.openhab.binding.airquality.internal.discovery; import static org.openhab.binding.airquality.internal.AirQualityBindingConstants.*; -import static org.openhab.binding.airquality.internal.AirQualityConfiguration.LOCATION; +import static org.openhab.binding.airquality.internal.config.AirQualityConfiguration.LOCATION; -import java.util.HashMap; -import java.util.Map; -import java.util.Objects; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.TimeUnit; +import java.util.Collections; +import java.util.Set; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.airquality.internal.handler.AirQualityBridgeHandler; import org.openhab.core.config.discovery.AbstractDiscoveryService; import org.openhab.core.config.discovery.DiscoveryResultBuilder; import org.openhab.core.config.discovery.DiscoveryService; import org.openhab.core.i18n.LocationProvider; import org.openhab.core.library.types.PointType; +import org.openhab.core.thing.ThingTypeUID; import org.openhab.core.thing.ThingUID; -import org.osgi.service.component.annotations.Activate; +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.Modified; -import org.osgi.service.component.annotations.Reference; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -43,80 +41,60 @@ */ @Component(service = DiscoveryService.class, configurationPid = "discovery.airquality") @NonNullByDefault -public class AirQualityDiscoveryService extends AbstractDiscoveryService { - private final Logger logger = LoggerFactory.getLogger(AirQualityDiscoveryService.class); +public class AirQualityDiscoveryService extends AbstractDiscoveryService implements ThingHandlerService { + private static final int DISCOVER_TIMEOUT_SECONDS = 2; + private static final Set SUPPORTED_THING_TYPES_UIDS = Collections.singleton(THING_TYPE_STATION); - private static final int DISCOVER_TIMEOUT_SECONDS = 10; - private static final int LOCATION_CHANGED_CHECK_INTERVAL = 60; + private final Logger logger = LoggerFactory.getLogger(AirQualityDiscoveryService.class); - private final LocationProvider locationProvider; - private @Nullable ScheduledFuture discoveryJob; - private @Nullable PointType previousLocation; + private @Nullable LocationProvider locationProvider; + private @Nullable AirQualityBridgeHandler bridgeHandler; /** * Creates a AirQualityDiscoveryService with enabled autostart. */ - @Activate - public AirQualityDiscoveryService(@Reference LocationProvider locationProvider) { - super(SUPPORTED_THING_TYPES, DISCOVER_TIMEOUT_SECONDS, true); - this.locationProvider = locationProvider; + public AirQualityDiscoveryService() { + super(SUPPORTED_THING_TYPES_UIDS, DISCOVER_TIMEOUT_SECONDS, false); } @Override - protected void activate(@Nullable Map configProperties) { - super.activate(configProperties); + public void setThingHandler(@Nullable ThingHandler handler) { + if (handler instanceof AirQualityBridgeHandler) { + final AirQualityBridgeHandler bridgeHandler = (AirQualityBridgeHandler) handler; + this.bridgeHandler = bridgeHandler; + this.locationProvider = bridgeHandler.getLocationProvider(); + } } @Override - @Modified - protected void modified(@Nullable Map configProperties) { - super.modified(configProperties); + public @Nullable ThingHandler getThingHandler() { + return bridgeHandler; } @Override - protected void startScan() { - logger.debug("Starting Air Quality discovery scan"); - PointType location = locationProvider.getLocation(); - if (location == null) { - logger.debug("LocationProvider.getLocation() is not set -> Will not provide any discovery results"); - return; - } - createResults(location); + public void deactivate() { + super.deactivate(); } @Override - protected void startBackgroundDiscovery() { - if (discoveryJob == null) { - discoveryJob = scheduler.scheduleWithFixedDelay(() -> { - PointType currentLocation = locationProvider.getLocation(); - if (currentLocation != null && !Objects.equals(currentLocation, previousLocation)) { - logger.debug("Location has been changed from {} to {}: Creating new discovery results", - previousLocation, currentLocation); - createResults(currentLocation); - previousLocation = currentLocation; - } - }, 0, LOCATION_CHANGED_CHECK_INTERVAL, TimeUnit.SECONDS); - logger.debug("Scheduled Air Qualitylocation-changed job every {} seconds", LOCATION_CHANGED_CHECK_INTERVAL); + protected void startScan() { + logger.debug("Starting Air Quality discovery scan"); + LocationProvider provider = locationProvider; + if (provider != null) { + PointType location = provider.getLocation(); + AirQualityBridgeHandler bridge = this.bridgeHandler; + if (location == null || bridge == null) { + logger.debug("LocationProvider.getLocation() is not set -> Will not provide any discovery results"); + return; + } + createResults(location, bridge.getThing().getUID()); } } - public void createResults(PointType location) { - ThingUID localAirQualityThing = new ThingUID(THING_TYPE_AQI, LOCAL); - Map properties = new HashMap<>(); - properties.put(LOCATION, String.format("%s,%s", location.getLatitude(), location.getLongitude())); + public void createResults(PointType location, ThingUID bridgeUID) { + ThingUID localAirQualityThing = new ThingUID(THING_TYPE_STATION, bridgeUID, LOCAL); thingDiscovered(DiscoveryResultBuilder.create(localAirQualityThing).withLabel("Local Air Quality") - .withProperties(properties).build()); - } - - @Override - protected void stopBackgroundDiscovery() { - logger.debug("Stopping Air Quality background discovery"); - ScheduledFuture job = this.discoveryJob; - if (job != null && !job.isCancelled()) { - if (job.cancel(true)) { - discoveryJob = null; - logger.debug("Stopped Air Quality background discovery"); - } - } + .withProperty(LOCATION, String.format("%s,%s", location.getLatitude(), location.getLongitude())) + .withBridge(bridgeUID).build()); } } diff --git a/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/handler/AirQualityBridgeHandler.java b/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/handler/AirQualityBridgeHandler.java new file mode 100644 index 0000000000000..75974083337d6 --- /dev/null +++ b/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/handler/AirQualityBridgeHandler.java @@ -0,0 +1,76 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.airquality.internal.handler; + +import java.util.Collection; +import java.util.Collections; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.airquality.internal.api.ApiBridge; +import org.openhab.binding.airquality.internal.discovery.AirQualityDiscoveryService; +import org.openhab.core.i18n.LocationProvider; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.ChannelUID; +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; + +/** + * The {@link AirQualityBridgeHandler} is responsible for handling communication + * with the service via the API. + * + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +public class AirQualityBridgeHandler extends BaseBridgeHandler { + private final LocationProvider locationProvider; + private @Nullable ApiBridge apiBridge; + + public AirQualityBridgeHandler(Bridge bridge, LocationProvider locationProvider) { + super(bridge); + this.locationProvider = locationProvider; + } + + @Override + public void initialize() { + String apiKey = (String) getConfig().get("apiKey"); + if (apiKey != null && apiKey.length() != 0) { + apiBridge = new ApiBridge(apiKey); + updateStatus(ThingStatus.ONLINE); + } else { + apiBridge = null; + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/null-or-empty-api-key"); + } + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + // We do nothing + } + + public @Nullable ApiBridge getApiBridge() { + return apiBridge; + } + + @Override + public Collection> getServices() { + return Collections.singleton(AirQualityDiscoveryService.class); + } + + public LocationProvider getLocationProvider() { + return locationProvider; + } +} diff --git a/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/handler/AirQualityHandler.java b/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/handler/AirQualityHandler.java deleted file mode 100644 index 3caf969eddc2f..0000000000000 --- a/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/handler/AirQualityHandler.java +++ /dev/null @@ -1,285 +0,0 @@ -/** - * Copyright (c) 2010-2021 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.binding.airquality.internal.handler; - -import static org.openhab.binding.airquality.internal.AirQualityBindingConstants.*; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.TimeUnit; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; -import org.openhab.binding.airquality.internal.AirQualityConfiguration; -import org.openhab.binding.airquality.internal.json.AirQualityJsonData; -import org.openhab.binding.airquality.internal.json.AirQualityJsonResponse; -import org.openhab.binding.airquality.internal.json.AirQualityJsonResponse.ResponseStatus; -import org.openhab.core.i18n.TimeZoneProvider; -import org.openhab.core.io.net.http.HttpUtil; -import org.openhab.core.library.types.DateTimeType; -import org.openhab.core.library.types.DecimalType; -import org.openhab.core.library.types.HSBType; -import org.openhab.core.library.types.PointType; -import org.openhab.core.library.types.QuantityType; -import org.openhab.core.library.types.StringType; -import org.openhab.core.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.openhab.core.types.UnDefType; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.google.gson.Gson; -import com.google.gson.JsonSyntaxException; - -/** - * The {@link AirQualityHandler} is responsible for handling commands, which are - * sent to one of the channels. - * - * @author Kuba Wolanin - Initial contribution - * @author Łukasz Dywicki - Initial contribution - */ -@NonNullByDefault -public class AirQualityHandler extends BaseThingHandler { - private static final String URL = "http://api.waqi.info/feed/%QUERY%/?token=%apikey%"; - private static final int REQUEST_TIMEOUT_MS = (int) TimeUnit.SECONDS.toMillis(30); - private final Logger logger = LoggerFactory.getLogger(AirQualityHandler.class); - private @Nullable ScheduledFuture refreshJob; - - private final Gson gson; - - private int retryCounter = 0; - private final TimeZoneProvider timeZoneProvider; - - public AirQualityHandler(Thing thing, Gson gson, TimeZoneProvider timeZoneProvider) { - super(thing); - this.gson = gson; - this.timeZoneProvider = timeZoneProvider; - } - - @Override - public void initialize() { - logger.debug("Initializing Air Quality handler."); - - AirQualityConfiguration config = getConfigAs(AirQualityConfiguration.class); - logger.debug("config apikey = (omitted from logging)"); - logger.debug("config location = {}", config.location); - logger.debug("config stationId = {}", config.stationId); - logger.debug("config refresh = {}", config.refresh); - - List errorMsg = new ArrayList<>(); - - if (config.apikey.trim().isEmpty()) { - errorMsg.add("Parameter 'apikey' is mandatory and must be configured"); - } - if (config.location.trim().isEmpty() && config.stationId == null) { - errorMsg.add("Parameter 'location' or 'stationId' is mandatory and must be configured"); - } - if (config.refresh < 30) { - errorMsg.add("Parameter 'refresh' must be at least 30 minutes"); - } - - if (errorMsg.isEmpty()) { - ScheduledFuture job = this.refreshJob; - if (job == null || job.isCancelled()) { - refreshJob = scheduler.scheduleWithFixedDelay(this::updateAndPublishData, 0, config.refresh, - TimeUnit.MINUTES); - } - } else { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, String.join(", ", errorMsg)); - } - } - - private void updateAndPublishData() { - retryCounter = 0; - AirQualityJsonData aqiResponse = getAirQualityData(); - if (aqiResponse != null) { - // Update all channels from the updated AQI data - getThing().getChannels().stream().filter(channel -> isLinked(channel.getUID().getId())).forEach(channel -> { - String channelId = channel.getUID().getId(); - State state = getValue(channelId, aqiResponse); - updateState(channelId, state); - }); - } - } - - @Override - public void dispose() { - logger.debug("Disposing the Air Quality handler."); - ScheduledFuture job = this.refreshJob; - if (job != null && !job.isCancelled()) { - job.cancel(true); - refreshJob = null; - } - } - - @Override - public void handleCommand(ChannelUID channelUID, Command command) { - if (command instanceof RefreshType) { - updateAndPublishData(); - } else { - logger.debug("The Air Quality binding is read-only and can not handle command {}", command); - } - } - - /** - * Build request URL from configuration data - * - * @return a valid URL for the aqicn.org service - */ - private String buildRequestURL() { - AirQualityConfiguration config = getConfigAs(AirQualityConfiguration.class); - - String location = config.location.trim(); - Integer stationId = config.stationId; - - String geoStr = "geo:" + location.replace(" ", "").replace(",", ";").replace("\"", "").replace("'", "").trim(); - - String urlStr = URL.replace("%apikey%", config.apikey.trim()); - - return urlStr.replace("%QUERY%", stationId == null ? geoStr : "@" + stationId); - } - - /** - * Request new air quality data to the aqicn.org service - * - * @param location geo-coordinates from config - * @param stationId station ID from config - * @return the air quality data object mapping the JSON response or null in case of error - */ - private @Nullable AirQualityJsonData getAirQualityData() { - String errorMsg; - - String urlStr = buildRequestURL(); - logger.debug("URL = {}", urlStr); - - try { - String response = HttpUtil.executeUrl("GET", urlStr, null, null, null, REQUEST_TIMEOUT_MS); - logger.debug("aqiResponse = {}", response); - AirQualityJsonResponse result = gson.fromJson(response, AirQualityJsonResponse.class); - if (result.getStatus() == ResponseStatus.OK) { - AirQualityJsonData data = result.getData(); - String attributions = data.getAttributions(); - updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, attributions); - return data; - } else { - retryCounter++; - if (retryCounter == 1) { - logger.warn("Error in aqicn.org, retrying once"); - return getAirQualityData(); - } - errorMsg = "Missing data sub-object"; - logger.warn("Error in aqicn.org response: {}", errorMsg); - } - } catch (IOException e) { - errorMsg = e.getMessage(); - } catch (JsonSyntaxException e) { - errorMsg = "Configuration is incorrect"; - logger.warn("Error running aqicn.org request: {}", errorMsg); - } - - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, errorMsg); - return null; - } - - public State getValue(String channelId, AirQualityJsonData aqiResponse) { - String[] fields = channelId.split("#"); - - switch (fields[0]) { - case AQI: - return new DecimalType(aqiResponse.getAqi()); - case AQIDESCRIPTION: - return getAqiDescription(aqiResponse.getAqi()); - case PM25: - case PM10: - case O3: - case NO2: - case CO: - case SO2: - double value = aqiResponse.getIaqiValue(fields[0]); - return value != -1 ? new DecimalType(value) : UnDefType.UNDEF; - case TEMPERATURE: - double temp = aqiResponse.getIaqiValue("t"); - return temp != -1 ? new QuantityType<>(temp, API_TEMPERATURE_UNIT) : UnDefType.UNDEF; - case PRESSURE: - double press = aqiResponse.getIaqiValue("p"); - return press != -1 ? new QuantityType<>(press, API_PRESSURE_UNIT) : UnDefType.UNDEF; - case HUMIDITY: - double hum = aqiResponse.getIaqiValue("h"); - return hum != -1 ? new QuantityType<>(hum, API_HUMIDITY_UNIT) : UnDefType.UNDEF; - case LOCATIONNAME: - return new StringType(aqiResponse.getCity().getName()); - case STATIONID: - return new DecimalType(aqiResponse.getStationId()); - case STATIONLOCATION: - return new PointType(aqiResponse.getCity().getGeo()); - case OBSERVATIONTIME: - return new DateTimeType( - aqiResponse.getTime().getObservationTime().withZoneSameLocal(timeZoneProvider.getTimeZone())); - case DOMINENTPOL: - return new StringType(aqiResponse.getDominentPol()); - case AQI_COLOR: - return getAsHSB(aqiResponse.getAqi()); - default: - return UnDefType.UNDEF; - } - } - - /** - * Interprets the current aqi value within the ranges; - * Returns AQI in a human readable format - * - * @return - */ - public State getAqiDescription(int index) { - if (index >= 300) { - return HAZARDOUS; - } else if (index >= 201) { - return VERY_UNHEALTHY; - } else if (index >= 151) { - return UNHEALTHY; - } else if (index >= 101) { - return UNHEALTHY_FOR_SENSITIVE; - } else if (index >= 51) { - return MODERATE; - } else if (index > 0) { - return GOOD; - } - return UnDefType.UNDEF; - } - - private State getAsHSB(int index) { - State state = getAqiDescription(index); - if (state == HAZARDOUS) { - return HSBType.fromRGB(343, 100, 49); - } else if (state == VERY_UNHEALTHY) { - return HSBType.fromRGB(280, 100, 60); - } else if (state == UNHEALTHY) { - return HSBType.fromRGB(345, 100, 80); - } else if (state == UNHEALTHY_FOR_SENSITIVE) { - return HSBType.fromRGB(30, 80, 100); - } else if (state == MODERATE) { - return HSBType.fromRGB(50, 80, 100); - } else if (state == GOOD) { - return HSBType.fromRGB(160, 100, 60); - } - return UnDefType.UNDEF; - } -} diff --git a/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/handler/AirQualityStationHandler.java b/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/handler/AirQualityStationHandler.java new file mode 100644 index 0000000000000..90a83321dd35d --- /dev/null +++ b/bundles/org.openhab.binding.airquality/src/main/java/org/openhab/binding/airquality/internal/handler/AirQualityStationHandler.java @@ -0,0 +1,298 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.airquality.internal.handler; + +import static org.openhab.binding.airquality.internal.AirQualityBindingConstants.*; +import static org.openhab.core.library.unit.MetricPrefix.*; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.airquality.internal.AirQualityException; +import org.openhab.binding.airquality.internal.api.ApiBridge; +import org.openhab.binding.airquality.internal.api.Appreciation; +import org.openhab.binding.airquality.internal.api.Index; +import org.openhab.binding.airquality.internal.api.Pollutant; +import org.openhab.binding.airquality.internal.api.Pollutant.SensitiveGroup; +import org.openhab.binding.airquality.internal.api.dto.AirQualityData; +import org.openhab.binding.airquality.internal.config.AirQualityConfiguration; +import org.openhab.binding.airquality.internal.config.SensitiveGroupConfiguration; +import org.openhab.core.config.core.Configuration; +import org.openhab.core.i18n.LocationProvider; +import org.openhab.core.i18n.TimeZoneProvider; +import org.openhab.core.library.types.DateTimeType; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.PointType; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.library.types.RawType; +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.Bridge; +import org.openhab.core.thing.Channel; +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.BridgeHandler; +import org.openhab.core.thing.binding.builder.ThingBuilder; +import org.openhab.core.thing.type.ChannelTypeUID; +import org.openhab.core.types.Command; +import org.openhab.core.types.RefreshType; +import org.openhab.core.types.State; +import org.openhab.core.types.UnDefType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link AirQualityStationHandler} is responsible for handling commands, which are + * sent to one of the channels. + * + * @author Kuba Wolanin - Initial contribution + * @author Łukasz Dywicki - Initial contribution + */ +@NonNullByDefault +public class AirQualityStationHandler extends BaseThingHandler { + private final @NonNullByDefault({}) ClassLoader classLoader = AirQualityStationHandler.class.getClassLoader(); + private final Logger logger = LoggerFactory.getLogger(AirQualityStationHandler.class); + private final TimeZoneProvider timeZoneProvider; + private final LocationProvider locationProvider; + + private @Nullable ScheduledFuture refreshJob; + + public AirQualityStationHandler(Thing thing, TimeZoneProvider timeZoneProvider, LocationProvider locationProvider) { + super(thing); + this.timeZoneProvider = timeZoneProvider; + this.locationProvider = locationProvider; + } + + @Override + public void initialize() { + logger.debug("Initializing Air Quality handler."); + + if (thing.getProperties().isEmpty()) { + discoverAttributes(); + } + + AirQualityConfiguration config = getConfigAs(AirQualityConfiguration.class); + try { + config.checkValid(); + freeRefreshJob(); + refreshJob = scheduler.scheduleWithFixedDelay(this::updateAndPublishData, 0, config.refresh, + TimeUnit.MINUTES); + } catch (AirQualityException e) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage()); + } + } + + private void discoverAttributes() { + getAirQualityData().ifPresent(data -> { + // Update thing properties + Map properties = new HashMap<>(); + properties.put(ATTRIBUTIONS, data.getAttributions()); + PointType serverLocation = locationProvider.getLocation(); + if (serverLocation != null) { + PointType stationLocation = new PointType(data.getCity().getGeo()); + double distance = serverLocation.distanceFrom(stationLocation).doubleValue(); + properties.put(DISTANCE, new QuantityType<>(distance / 1000, KILO(SIUnits.METRE)).toString()); + } + + // Search and remove missing pollutant channels + List channels = new ArrayList<>(getThing().getChannels()); + Stream.of(Pollutant.values()).forEach(pollutant -> { + String groupName = pollutant.name().toLowerCase(); + double value = data.getIaqiValue(pollutant); + channels.removeIf(channel -> value == -1 && groupName.equals(channel.getUID().getGroupId())); + }); + + // Update thing configuration + Configuration config = editConfiguration(); + config.put(AirQualityConfiguration.STATION_ID, data.getStationId()); + + ThingBuilder thingBuilder = editThing(); + thingBuilder.withChannels(channels).withConfiguration(config).withProperties(properties) + .withLocation(data.getCity().getName()); + updateThing(thingBuilder.build()); + }); + } + + private void updateAndPublishData() { + getAirQualityData().ifPresent(data -> { + getThing().getChannels().stream().filter(channel -> isLinked(channel.getUID().getId())).forEach(channel -> { + State state; + ChannelUID channelUID = channel.getUID(); + ChannelTypeUID channelTypeUID = channel.getChannelTypeUID(); + if (channelTypeUID != null && SENSITIVE.equals(channelTypeUID.getId().toString())) { + SensitiveGroupConfiguration configuration = channel.getConfiguration() + .as(SensitiveGroupConfiguration.class); + state = getSensitive(configuration.asSensitiveGroup(), data); + } else { + state = getValue(channelUID.getIdWithoutGroup(), channelUID.getGroupId(), data); + } + updateState(channelUID, state); + }); + }); + } + + @Override + public void dispose() { + logger.debug("Disposing the Air Quality handler."); + freeRefreshJob(); + } + + private void freeRefreshJob() { + ScheduledFuture job = this.refreshJob; + if (job != null && !job.isCancelled()) { + job.cancel(true); + refreshJob = null; + } + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + if (command instanceof RefreshType) { + updateAndPublishData(); + return; + } + logger.debug("The Air Quality binding is read-only and can not handle command {}", command); + } + + /** + * Request new air quality data to the aqicn.org service + * + * @return an optional air quality data object mapping the JSON response + */ + private Optional getAirQualityData() { + AirQualityData result = null; + ApiBridge apiBridge = getApiBridge(); + if (apiBridge != null) { + AirQualityConfiguration config = getConfigAs(AirQualityConfiguration.class); + try { + result = apiBridge.getData(config.stationId, config.location, 0); + updateStatus(ThingStatus.ONLINE); + } catch (AirQualityException e) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage()); + } + } + return Optional.ofNullable(result); + } + + private @Nullable ApiBridge getApiBridge() { + Bridge bridge = this.getBridge(); + if (bridge != null && bridge.getStatus() == ThingStatus.ONLINE) { + BridgeHandler handler = bridge.getHandler(); + if (handler instanceof AirQualityBridgeHandler) { + return ((AirQualityBridgeHandler) handler).getApiBridge(); + } else { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/incorrect-bridge"); + } + } else { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE); + } + return null; + } + + private State indexedValue(String channelId, double idx, @Nullable Pollutant pollutant) { + Index index = Index.find(idx); + if (index != null) { + switch (channelId) { + case INDEX: + return new DecimalType(idx); + case VALUE: + return pollutant != null ? pollutant.toQuantity(idx) : UnDefType.UNDEF; + case ICON: + byte[] bytes = getResource(String.format("picto/%s.svg", index.getCategory().name().toLowerCase())); + return bytes != null ? new RawType(bytes, "image/svg+xml") : UnDefType.UNDEF; + case COLOR: + return index.getCategory().getColor(); + case ALERT_LEVEL: + return new DecimalType(index.getCategory().ordinal()); + } + } + return UnDefType.UNDEF; + } + + private State getSensitive(@Nullable SensitiveGroup sensitiveGroup, AirQualityData data) { + if (sensitiveGroup != null) { + int threshHold = Appreciation.UNHEALTHY_FSG.ordinal(); + for (Pollutant pollutant : Pollutant.values()) { + Index index = Index.find(data.getIaqiValue(pollutant)); + if (index != null && pollutant.sensitiveGroups.contains(sensitiveGroup) + && index.getCategory().ordinal() >= threshHold) { + return OnOffType.ON; + } + } + return OnOffType.OFF; + } + return UnDefType.NULL; + } + + private State getValue(String channelId, @Nullable String groupId, AirQualityData data) { + switch (channelId) { + case TEMPERATURE: + double temp = data.getIaqiValue("t"); + return temp != -1 ? new QuantityType<>(temp, SIUnits.CELSIUS) : UnDefType.UNDEF; + case PRESSURE: + double press = data.getIaqiValue("p"); + return press != -1 ? new QuantityType<>(press, HECTO(SIUnits.PASCAL)) : UnDefType.UNDEF; + case HUMIDITY: + double hum = data.getIaqiValue("h"); + return hum != -1 ? new QuantityType<>(hum, Units.PERCENT) : UnDefType.UNDEF; + case TIMESTAMP: + return new DateTimeType( + data.getTime().getObservationTime().withZoneSameLocal(timeZoneProvider.getTimeZone())); + case DOMINENT: + return new StringType(data.getDominentPol()); + case DEW_POINT: + double dp = data.getIaqiValue("dew"); + return dp != -1 ? new QuantityType<>(dp, SIUnits.CELSIUS) : UnDefType.UNDEF; + case WIND_SPEED: + double w = data.getIaqiValue("w"); + return w != -1 ? new QuantityType<>(w, Units.METRE_PER_SECOND) : UnDefType.UNDEF; + default: + if (groupId != null) { + double idx = -1; + Pollutant pollutant = null; + if (AQI.equals(groupId)) { + idx = data.getAqi(); + } else { + pollutant = Pollutant.valueOf(groupId.toUpperCase()); + idx = data.getIaqiValue(pollutant); + } + return indexedValue(channelId, idx, pollutant); + } + return UnDefType.UNDEF; + } + } + + private byte @Nullable [] getResource(String iconPath) { + try (InputStream stream = classLoader.getResourceAsStream(iconPath)) { + return stream != null ? stream.readAllBytes() : null; + } catch (IOException e) { + logger.warn("Unable to load ressource '{}' : {}", iconPath, e.getMessage()); + } + return null; + } +} diff --git a/bundles/org.openhab.binding.airquality/src/main/resources/OH-INF/binding/binding.xml b/bundles/org.openhab.binding.airquality/src/main/resources/OH-INF/binding/binding.xml index d0cdc8c3812b8..09d63db67533c 100644 --- a/bundles/org.openhab.binding.airquality/src/main/resources/OH-INF/binding/binding.xml +++ b/bundles/org.openhab.binding.airquality/src/main/resources/OH-INF/binding/binding.xml @@ -4,6 +4,5 @@ xsi:schemaLocation="https://openhab.org/schemas/binding/v1.0.0 https://openhab.org/schemas/binding-1.0.0.xsd"> Air Quality Binding - Measure Air Quality Index and details about pollution particles for a given location diff --git a/bundles/org.openhab.binding.airquality/src/main/resources/OH-INF/i18n/airquality.properties b/bundles/org.openhab.binding.airquality/src/main/resources/OH-INF/i18n/airquality.properties new file mode 100644 index 0000000000000..776d424613a09 --- /dev/null +++ b/bundles/org.openhab.binding.airquality/src/main/resources/OH-INF/i18n/airquality.properties @@ -0,0 +1,62 @@ +# binding +binding.airquality.name = Air Quality Binding +binding.airquality.description = Measure Air Quality Index and details about pollution particles for a given location. + +# thing types +thing-type.airquality.api.label = Air Quality API +thing-type.airquality.api.description = Bridge to the Air Quality API service. +thing-type.airquality.station.label = Air Quality Station +thing-type.airquality.station.description = Provides various air quality data from the World Air Quality Project. In order to receive the data, you must register an account on http://aqicn.org/data-platform/token/ and get your API token. + +# thing types config +thing-type.config.airquality.api.apiKey.label = API Key +thing-type.config.airquality.api.apiKey.description = Data-platform token to access the AQIcn.org service. +thing-type.config.airquality.station.location.label = Location +thing-type.config.airquality.station.location.description = Your geo coordinates separated with comma (e.g. "37.8,-122.4"). +thing-type.config.airquality.station.stationId.label = Station ID +thing-type.config.airquality.station.stationId.description = Fill in case you want to receive data from the specific station. +thing-type.config.airquality.station.refresh.label = Refresh Interval +thing-type.config.airquality.station.refresh.description = Specifies the refresh interval in minutes. + +# Channel groups labels +pm25ChannelGroupLabel = PM 2.5 - Particles less than 2.5 m in diameter. +pm10ChannelGroupLabel = PM 10 - Coarse Dust Particles. +no2ChannelGroupLabel = NO2 - Nitrogen Dioxide. +so2ChannelGroupLabel = SO2 - Sulfur Dioxide. +coChannelGroupLabel = CO - Carbon Monoxide. +o3ChannelGroupLabel = O3 - Ozone. +aqiChannelGroupLabel = AQI - Synthetic Air Quality Index. +weatherChannelGroupLabel = Weather Data + +# Channel types labels +timestampChannelLabel = Observation Time +timestampChannelDescription = Observation date and time. +dominentChannelLabel = Dominent Pollutant +dewPointLabel = Dew-Point Temperature +dewPointDescription = Forecasted dew-point temperature. +windSpeedLabel = Wind Speed +pictoChannelLabel = Pictogram +pictoChannelDescription = Pictogram associated to alert level. +colorChannelLabel = AQI Color +colorChannelDescription = Color associated to given AQI Index. + +# Channel options values +alertLevelChannelLabel = Alert Level +alertLevelChannelDescription = Alert level associated to Air Quality Index scale. +alertLevelOption0 = Good +alertLevelOption1 = Moderate +alertLevelOption2 = Unhealthy for Sensitive Groups +alertLevelOption3 = Unhealthy +alertLevelOption4 = Very Unhealthy +alertLevelOption5 = Hazardous + +pollutantPm25 = Fine particles +pollutantPm10 = Coarse dust particles +pollutantO3 = Ozone +pollutantNO2 = Nitrogen Dioxide +pollutantCO = Carbon Monoxide +pollutantSO2 = Sulfur Dioxide + +# Error messages +null-or-empty-api-key = Null or empty API key +incorrect-bridge = Wrong bridge type diff --git a/bundles/org.openhab.binding.airquality/src/main/resources/OH-INF/i18n/airquality_fr.properties b/bundles/org.openhab.binding.airquality/src/main/resources/OH-INF/i18n/airquality_fr.properties index aa947a41d1cc1..7cfe92f15b2f2 100644 --- a/bundles/org.openhab.binding.airquality/src/main/resources/OH-INF/i18n/airquality_fr.properties +++ b/bundles/org.openhab.binding.airquality/src/main/resources/OH-INF/i18n/airquality_fr.properties @@ -1,30 +1,62 @@ # binding binding.airquality.name = Extension Air Quality -binding.airquality.description = Indice de qualité de l'air et informations sur la pollution aux particules pour un emplacement donné. +binding.airquality.description = Indice de qualit de l'air et informations sur la pollution aux particules pour un emplacement donn. # thing types -thing-type.airquality.aqi.label = Qualité de l'air -thing-type.airquality.aqi.description = Fournit diverses données sur la qualité de l'air du World Air Quality Project. Pour recevoir les données, vous devez créer un compte sur http://aqicn.org/data-platform/token/ pour obtenir votre token API. +thing-type.airquality.api.label = API Air Quality +thing-type.airquality.api.description = Passerelle vers le service de donnes Air Quality. +thing-type.airquality.station.label = Qualit de l'air +thing-type.airquality.station.description = Fournit diverses donnes sur la qualit de l'air du World Air Quality Project. Pour recevoir les donnes, vous devez crer un compte sur http://aqicn.org/data-platform/token/ pour obtenir votre token API. -channel-type.airquality.aqiLevel.label = Indice -channel-type.airquality.aqiDescription.label = Appréciation -channel-type.airquality.observationTime.label = Heure d'observation -channel-type.airquality.temperature.label = Température -channel-type.airquality.pressure.label = Pression -channel-type.airquality.humidity.label = Humidité -channel-type.airquality.dominentpol.label = Polluant principal +# thing types config +thing-type.config.airquality.api.apiKey.label = Cl API +thing-type.config.airquality.api.apiKey.description = Jeton d'accs aux donnes du service AQIcn.org. +thing-type.config.airquality.station.location.label = Localisation +thing-type.config.airquality.station.location.description = Coordonnes gographiques (spares par une virgule : "37.8,-122.4"). +thing-type.config.airquality.station.stationId.label = ID Station +thing-type.config.airquality.station.stationId.description = Renseignez cette valeur si vous souhaitez les donnes d'une station spcifique. +thing-type.config.airquality.station.refresh.label = Priode de mise jour +thing-type.config.airquality.station.refresh.description = Dfinisser l'intervalle de mise jour en minutes. +# Channel groups labels +pm25ChannelGroupLabel = PM 2.5 - Particules de diametre infrieur 2.5 m. +pm10ChannelGroupLabel = PM 10 - Particules fines de poussire. +no2ChannelGroupLabel = NO2 - Dioxyde d'azote. +so2ChannelGroupLabel = SO2 - Dioxyde de soufre. +coChannelGroupLabel = CO - Monoxyde de carbone. +o3ChannelGroupLabel = O3 - Ozone. +aqiChannelGroupLabel = AQI - Indice Global de Qualit de l'Air. +weatherChannelGroupLabel = Donnes Mteo. -channel-type.airquality.aqiDescription.state.option.GOOD = Bonne -channel-type.airquality.aqiDescription.state.option.MODERATE = Modérée -channel-type.airquality.aqiDescription.state.option.UNHEALTHY_FOR_SENSITIVE = Mauvaise pour les groupes sensibles -channel-type.airquality.aqiDescription.state.option.UNHEALTHY = Mauvaise -channel-type.airquality.aqiDescription.state.option.VERY_UNHEALTHY = Très mauvaise -channel-type.airquality.aqiDescription.state.option.HAZARDOUS = Dangereuse +# Channel types labels +timestampChannelLabel = Horodatage +timestampChannelDescription = Date et heure des oObservations. +dominentChannelLabel = Polluant Principal +dewPointLabel = Temprature de Rose +dewPointDescription = Temprature du point de Rose. +windSpeedLabel = Vitesse du Vent +pictoChannelLabel = Pictogramme +pictoChannelDescription = Pictogramme associ au niveau d'alerte. +colorChannelLabel = Couleur AQI +colorChannelDescription = Couleur associe l'Indice de Qualit d'Air. -channel-type.airquality.dominentPol.state.option.pm25 = Particules fines -channel-type.airquality.dominentPol.state.option.pm10 = Particules de poussière -channel-type.airquality.dominentPol.state.option.o3 = Ozone -channel-type.airquality.dominentPol.state.option.no2 = Dioxyde d'azote -channel-type.airquality.dominentPol.state.option.co = Monoxyde de carbone -channel-type.airquality.dominentPol.state.option.so2 = Dioxyde de soufre +# Channel options values +alertLevelChannelLabel = Niveau d'Alerte +alertLevelChannelDescription = Niveau d'alerte associ l'Indice de Qualit d'Air. +alertLevelOption0 = Bonne +alertLevelOption1 = Modre +alertLevelOption2 = Mauvaise pour les groupes sensibles +alertLevelOption3 = Mauvaise +alertLevelOption4 = Trs mauvaise +alertLevelOption5 = Dangereuse + +pollutantPm25 = Particules de diametre infrieur 2.5 m. +pollutantPm10 = Particules fines de poussire. +pollutantO3 = Ozone +pollutantNO2 = Dioxyde d'azote. +pollutantCO = Monoxyde de carbone. +pollutantSO2 = Dioxyde de soufre. + +# Error messages +null-or-empty-api-key = Clef API nulle ou vide +incorrect-bridge = Le type de bridge est incorrect diff --git a/bundles/org.openhab.binding.airquality/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.airquality/src/main/resources/OH-INF/thing/thing-types.xml index e37e14388cc19..08a63ca52a699 100644 --- a/bundles/org.openhab.binding.airquality/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.airquality/src/main/resources/OH-INF/thing/thing-types.xml @@ -4,55 +4,58 @@ 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"> - - - - - Provides various air quality data from the World Air Quality Project. - In order to receive the data, you - must register an account on http://aqicn.org/data-platform/token/ and get your API - token. - - - - - - - - - - - - - - - - - - - - - + + - + password - Data-platform token to access the AQIcn.org service + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - Your geo coordinates separated with comma (e.g. "37.8,-122.4"). - Fill only in case you want to receive data from the specific station - true - Specifies the refresh interval in minutes. true 60 Minutes @@ -60,155 +63,172 @@ - - Number - - - Air Quality Index - - + + + + + + + + + + + - - String - - - AQI Description - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + Number - - Fine particles pollution level - PM2.5 + + + Measurement + - + Number - - Coarse dust particles pollution level - PM10 + + + Measurement + - - Number - - Ozone level - O3 - + + Number:Density + + - - Number - - Nitrogen dioxide level - NO2 - + + Number:Dimensionless + + - + Number - - Carbon monoxide level - CO - + + @text/alertLevelChannelDescription + error + + Alarm + + + + + + + + + + + - - Number - - Sulfur dioxide level - SO2 - + + DateTime + + @text/timestampChannelDescription + time + + Status + Timestamp + + - + String - - Nearest measuring station location - Location - - - - - Location - - Location of the measuring station - Station Location - - - - - Number - - Unique measuring station ID - Station ID - - - - - DateTime - - Observation date and time - Observation time - + + + Status + + + + + + + + + + + - + Number:Temperature - - Temperature + + @text/dewPointDescription Temperature + + Point + Temperature + - - Number:Pressure - - Current Pressure - Pressure - - - - - Number:Dimensionless - - Current humidity - Humidity - + + Color + + @text/colorChannelDescription + - - String - - - - - - - - - - - + + Image + + @text/pictoChannelDescription + - - Color - - Color associated to given AQI Index. + + Switch + + Checked if sensitive group is exposed to pollutant. + + + + Defines the kind of sensitivity + + + + + + + + RESPIRATORY + + diff --git a/bundles/org.openhab.binding.airquality/src/main/resources/picto/good.svg b/bundles/org.openhab.binding.airquality/src/main/resources/picto/good.svg new file mode 100644 index 0000000000000..aecde2e179158 --- /dev/null +++ b/bundles/org.openhab.binding.airquality/src/main/resources/picto/good.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.airquality/src/main/resources/picto/hazardous.svg b/bundles/org.openhab.binding.airquality/src/main/resources/picto/hazardous.svg new file mode 100644 index 0000000000000..143e55e5fd176 --- /dev/null +++ b/bundles/org.openhab.binding.airquality/src/main/resources/picto/hazardous.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.airquality/src/main/resources/picto/moderate.svg b/bundles/org.openhab.binding.airquality/src/main/resources/picto/moderate.svg new file mode 100644 index 0000000000000..0926e7be2cb83 --- /dev/null +++ b/bundles/org.openhab.binding.airquality/src/main/resources/picto/moderate.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.airquality/src/main/resources/picto/unhealthy.svg b/bundles/org.openhab.binding.airquality/src/main/resources/picto/unhealthy.svg new file mode 100644 index 0000000000000..f654683ea76d2 --- /dev/null +++ b/bundles/org.openhab.binding.airquality/src/main/resources/picto/unhealthy.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.airquality/src/main/resources/picto/unhealthy_fsg.svg b/bundles/org.openhab.binding.airquality/src/main/resources/picto/unhealthy_fsg.svg new file mode 100644 index 0000000000000..1aca57635833d --- /dev/null +++ b/bundles/org.openhab.binding.airquality/src/main/resources/picto/unhealthy_fsg.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.airquality/src/main/resources/picto/very_unhealthy.svg b/bundles/org.openhab.binding.airquality/src/main/resources/picto/very_unhealthy.svg new file mode 100644 index 0000000000000..b04e3520a8138 --- /dev/null +++ b/bundles/org.openhab.binding.airquality/src/main/resources/picto/very_unhealthy.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file