2 * Copyright (c) 2010-2020 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.airquality.internal.handler;
15 import static org.openhab.binding.airquality.internal.AirQualityBindingConstants.*;
17 import java.io.IOException;
18 import java.util.ArrayList;
19 import java.util.List;
20 import java.util.concurrent.ScheduledFuture;
21 import java.util.concurrent.TimeUnit;
23 import org.eclipse.jdt.annotation.NonNullByDefault;
24 import org.eclipse.jdt.annotation.Nullable;
25 import org.openhab.binding.airquality.internal.AirQualityConfiguration;
26 import org.openhab.binding.airquality.internal.json.AirQualityJsonData;
27 import org.openhab.binding.airquality.internal.json.AirQualityJsonResponse;
28 import org.openhab.binding.airquality.internal.json.AirQualityJsonResponse.ResponseStatus;
29 import org.openhab.core.i18n.TimeZoneProvider;
30 import org.openhab.core.io.net.http.HttpUtil;
31 import org.openhab.core.library.types.DateTimeType;
32 import org.openhab.core.library.types.DecimalType;
33 import org.openhab.core.library.types.HSBType;
34 import org.openhab.core.library.types.PointType;
35 import org.openhab.core.library.types.QuantityType;
36 import org.openhab.core.library.types.StringType;
37 import org.openhab.core.thing.ChannelUID;
38 import org.openhab.core.thing.Thing;
39 import org.openhab.core.thing.ThingStatus;
40 import org.openhab.core.thing.ThingStatusDetail;
41 import org.openhab.core.thing.binding.BaseThingHandler;
42 import org.openhab.core.types.Command;
43 import org.openhab.core.types.RefreshType;
44 import org.openhab.core.types.State;
45 import org.openhab.core.types.UnDefType;
46 import org.slf4j.Logger;
47 import org.slf4j.LoggerFactory;
49 import com.google.gson.Gson;
50 import com.google.gson.JsonSyntaxException;
53 * The {@link AirQualityHandler} is responsible for handling commands, which are
54 * sent to one of the channels.
56 * @author Kuba Wolanin - Initial contribution
57 * @author Ćukasz Dywicki - Initial contribution
60 public class AirQualityHandler extends BaseThingHandler {
61 private static final String URL = "http://api.waqi.info/feed/%QUERY%/?token=%apikey%";
62 private static final int REQUEST_TIMEOUT_MS = (int) TimeUnit.SECONDS.toMillis(30);
63 private final Logger logger = LoggerFactory.getLogger(AirQualityHandler.class);
64 private @Nullable ScheduledFuture<?> refreshJob;
66 private final Gson gson;
68 private int retryCounter = 0;
69 private final TimeZoneProvider timeZoneProvider;
71 public AirQualityHandler(Thing thing, Gson gson, TimeZoneProvider timeZoneProvider) {
74 this.timeZoneProvider = timeZoneProvider;
78 public void initialize() {
79 logger.debug("Initializing Air Quality handler.");
81 AirQualityConfiguration config = getConfigAs(AirQualityConfiguration.class);
82 logger.debug("config apikey = (omitted from logging)");
83 logger.debug("config location = {}", config.location);
84 logger.debug("config stationId = {}", config.stationId);
85 logger.debug("config refresh = {}", config.refresh);
87 List<String> errorMsg = new ArrayList<>();
89 if (config.apikey.trim().isEmpty()) {
90 errorMsg.add("Parameter 'apikey' is mandatory and must be configured");
92 if (config.location.trim().isEmpty() && config.stationId == null) {
93 errorMsg.add("Parameter 'location' or 'stationId' is mandatory and must be configured");
95 if (config.refresh < 30) {
96 errorMsg.add("Parameter 'refresh' must be at least 30 minutes");
99 if (errorMsg.isEmpty()) {
100 ScheduledFuture<?> job = this.refreshJob;
101 if (job == null || job.isCancelled()) {
102 refreshJob = scheduler.scheduleWithFixedDelay(this::updateAndPublishData, 0, config.refresh,
106 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, String.join(", ", errorMsg));
110 private void updateAndPublishData() {
112 AirQualityJsonData aqiResponse = getAirQualityData();
113 if (aqiResponse != null) {
114 // Update all channels from the updated AQI data
115 getThing().getChannels().stream().filter(channel -> isLinked(channel.getUID().getId())).forEach(channel -> {
116 String channelId = channel.getUID().getId();
117 State state = getValue(channelId, aqiResponse);
118 updateState(channelId, state);
124 public void dispose() {
125 logger.debug("Disposing the Air Quality handler.");
126 ScheduledFuture<?> job = this.refreshJob;
127 if (job != null && !job.isCancelled()) {
134 public void handleCommand(ChannelUID channelUID, Command command) {
135 if (command instanceof RefreshType) {
136 updateAndPublishData();
138 logger.debug("The Air Quality binding is read-only and can not handle command {}", command);
143 * Build request URL from configuration data
145 * @return a valid URL for the aqicn.org service
147 private String buildRequestURL() {
148 AirQualityConfiguration config = getConfigAs(AirQualityConfiguration.class);
150 String location = config.location.trim();
151 Integer stationId = config.stationId;
153 String geoStr = "geo:" + location.replace(" ", "").replace(",", ";").replace("\"", "").replace("'", "").trim();
155 String urlStr = URL.replace("%apikey%", config.apikey.trim());
157 return urlStr.replace("%QUERY%", stationId == null ? geoStr : "@" + stationId);
161 * Request new air quality data to the aqicn.org service
163 * @param location geo-coordinates from config
164 * @param stationId station ID from config
165 * @return the air quality data object mapping the JSON response or null in case of error
167 private @Nullable AirQualityJsonData getAirQualityData() {
170 String urlStr = buildRequestURL();
171 logger.debug("URL = {}", urlStr);
174 String response = HttpUtil.executeUrl("GET", urlStr, null, null, null, REQUEST_TIMEOUT_MS);
175 logger.debug("aqiResponse = {}", response);
176 AirQualityJsonResponse result = gson.fromJson(response, AirQualityJsonResponse.class);
177 if (result.getStatus() == ResponseStatus.OK) {
178 AirQualityJsonData data = result.getData();
179 String attributions = data.getAttributions();
180 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, attributions);
184 if (retryCounter == 1) {
185 logger.warn("Error in aqicn.org, retrying once");
186 return getAirQualityData();
188 errorMsg = "Missing data sub-object";
189 logger.warn("Error in aqicn.org response: {}", errorMsg);
191 } catch (IOException e) {
192 errorMsg = e.getMessage();
193 } catch (JsonSyntaxException e) {
194 errorMsg = "Configuration is incorrect";
195 logger.warn("Error running aqicn.org request: {}", errorMsg);
198 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, errorMsg);
202 public State getValue(String channelId, AirQualityJsonData aqiResponse) {
203 String[] fields = channelId.split("#");
207 return new DecimalType(aqiResponse.getAqi());
209 return getAqiDescription(aqiResponse.getAqi());
216 double value = aqiResponse.getIaqiValue(fields[0]);
217 return value != -1 ? new DecimalType(value) : UnDefType.UNDEF;
219 double temp = aqiResponse.getIaqiValue("t");
220 return temp != -1 ? new QuantityType<>(temp, API_TEMPERATURE_UNIT) : UnDefType.UNDEF;
222 double press = aqiResponse.getIaqiValue("p");
223 return press != -1 ? new QuantityType<>(press, API_PRESSURE_UNIT) : UnDefType.UNDEF;
225 double hum = aqiResponse.getIaqiValue("h");
226 return hum != -1 ? new QuantityType<>(hum, API_HUMIDITY_UNIT) : UnDefType.UNDEF;
228 return new StringType(aqiResponse.getCity().getName());
230 return new DecimalType(aqiResponse.getStationId());
231 case STATIONLOCATION:
232 return new PointType(aqiResponse.getCity().getGeo());
233 case OBSERVATIONTIME:
234 return new DateTimeType(
235 aqiResponse.getTime().getObservationTime().withZoneSameLocal(timeZoneProvider.getTimeZone()));
237 return new StringType(aqiResponse.getDominentPol());
239 return getAsHSB(aqiResponse.getAqi());
241 return UnDefType.UNDEF;
246 * Interprets the current aqi value within the ranges;
247 * Returns AQI in a human readable format
251 public State getAqiDescription(int index) {
254 } else if (index >= 201) {
255 return VERY_UNHEALTHY;
256 } else if (index >= 151) {
258 } else if (index >= 101) {
259 return UNHEALTHY_FOR_SENSITIVE;
260 } else if (index >= 51) {
262 } else if (index > 0) {
265 return UnDefType.UNDEF;
268 private State getAsHSB(int index) {
269 State state = getAqiDescription(index);
270 if (state == HAZARDOUS) {
271 return HSBType.fromRGB(343, 100, 49);
272 } else if (state == VERY_UNHEALTHY) {
273 return HSBType.fromRGB(280, 100, 60);
274 } else if (state == UNHEALTHY) {
275 return HSBType.fromRGB(345, 100, 80);
276 } else if (state == UNHEALTHY_FOR_SENSITIVE) {
277 return HSBType.fromRGB(30, 80, 100);
278 } else if (state == MODERATE) {
279 return HSBType.fromRGB(50, 80, 100);
280 } else if (state == GOOD) {
281 return HSBType.fromRGB(160, 100, 60);
283 return UnDefType.UNDEF;