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.openweathermap.internal.connection;
15 import static java.util.stream.Collectors.joining;
16 import static org.eclipse.jetty.http.HttpMethod.GET;
17 import static org.eclipse.jetty.http.HttpStatus.*;
19 import java.io.UnsupportedEncodingException;
20 import java.net.URLEncoder;
21 import java.nio.charset.StandardCharsets;
22 import java.util.Arrays;
23 import java.util.HashMap;
24 import java.util.List;
26 import java.util.concurrent.ExecutionException;
27 import java.util.concurrent.TimeUnit;
28 import java.util.concurrent.TimeoutException;
30 import org.eclipse.jdt.annotation.NonNullByDefault;
31 import org.eclipse.jdt.annotation.Nullable;
32 import org.eclipse.jetty.client.HttpClient;
33 import org.eclipse.jetty.client.HttpResponseException;
34 import org.eclipse.jetty.client.api.ContentResponse;
35 import org.openhab.binding.openweathermap.internal.config.OpenWeatherMapAPIConfiguration;
36 import org.openhab.binding.openweathermap.internal.dto.OpenWeatherMapJsonDailyForecastData;
37 import org.openhab.binding.openweathermap.internal.dto.OpenWeatherMapJsonHourlyForecastData;
38 import org.openhab.binding.openweathermap.internal.dto.OpenWeatherMapJsonUVIndexData;
39 import org.openhab.binding.openweathermap.internal.dto.OpenWeatherMapJsonWeatherData;
40 import org.openhab.binding.openweathermap.internal.handler.OpenWeatherMapAPIHandler;
41 import org.openhab.binding.openweathermap.internal.utils.ByteArrayFileCache;
42 import org.openhab.core.cache.ExpiringCacheMap;
43 import org.openhab.core.io.net.http.HttpUtil;
44 import org.openhab.core.library.types.PointType;
45 import org.openhab.core.library.types.RawType;
46 import org.slf4j.Logger;
47 import org.slf4j.LoggerFactory;
49 import com.google.gson.Gson;
50 import com.google.gson.JsonElement;
51 import com.google.gson.JsonObject;
52 import com.google.gson.JsonParser;
53 import com.google.gson.JsonSyntaxException;
56 * The {@link OpenWeatherMapConnection} is responsible for handling the connections to OpenWeatherMap API.
58 * @author Christoph Weitkamp - Initial contribution
61 public class OpenWeatherMapConnection {
63 private final Logger logger = LoggerFactory.getLogger(OpenWeatherMapConnection.class);
65 private static final String PROPERTY_MESSAGE = "message";
67 private static final String PNG_CONTENT_TYPE = "image/png";
69 private static final String PARAM_APPID = "appid";
70 private static final String PARAM_UNITS = "units";
71 private static final String PARAM_LAT = "lat";
72 private static final String PARAM_LON = "lon";
73 private static final String PARAM_LANG = "lang";
74 private static final String PARAM_FORECAST_CNT = "cnt";
76 // Current weather data (see https://openweathermap.org/current)
77 private static final String WEATHER_URL = "https://api.openweathermap.org/data/2.5/weather";
78 // 5 day / 3 hour forecast (see https://openweathermap.org/forecast5)
79 private static final String THREE_HOUR_FORECAST_URL = "https://api.openweathermap.org/data/2.5/forecast";
80 // 16 day / daily forecast (see https://openweathermap.org/forecast16)
81 private static final String DAILY_FORECAST_URL = "https://api.openweathermap.org/data/2.5/forecast/daily";
82 // UV Index (see https://openweathermap.org/api/uvi)
83 private static final String UVINDEX_URL = "https://api.openweathermap.org/data/2.5/uvi";
84 private static final String UVINDEX_FORECAST_URL = "https://api.openweathermap.org/data/2.5/uvi/forecast";
85 // Weather icons (see https://openweathermap.org/weather-conditions)
86 private static final String ICON_URL = "https://openweathermap.org/img/w/%s.png";
88 private final OpenWeatherMapAPIHandler handler;
89 private final HttpClient httpClient;
91 private static final ByteArrayFileCache IMAGE_CACHE = new ByteArrayFileCache("org.openhab.binding.openweathermap");
92 private final ExpiringCacheMap<String, String> cache;
94 private final JsonParser parser = new JsonParser();
95 private final Gson gson = new Gson();
97 public OpenWeatherMapConnection(OpenWeatherMapAPIHandler handler, HttpClient httpClient) {
98 this.handler = handler;
99 this.httpClient = httpClient;
101 OpenWeatherMapAPIConfiguration config = handler.getOpenWeatherMapAPIConfig();
102 cache = new ExpiringCacheMap<>(TimeUnit.MINUTES.toMillis(config.refreshInterval));
106 * Requests the current weather data for the given location (see https://openweathermap.org/current).
108 * @param location location represented as {@link PointType}
109 * @return the current weather data
110 * @throws JsonSyntaxException
111 * @throws OpenWeatherMapCommunicationException
112 * @throws OpenWeatherMapConfigurationException
114 public synchronized @Nullable OpenWeatherMapJsonWeatherData getWeatherData(@Nullable PointType location)
115 throws JsonSyntaxException, OpenWeatherMapCommunicationException, OpenWeatherMapConfigurationException {
116 return gson.fromJson(
117 getResponseFromCache(
118 buildURL(WEATHER_URL, getRequestParams(handler.getOpenWeatherMapAPIConfig(), location))),
119 OpenWeatherMapJsonWeatherData.class);
123 * Requests the hourly forecast data for the given location (see https://openweathermap.org/forecast5).
125 * @param location location represented as {@link PointType}
126 * @param count number of hours
127 * @return the hourly forecast data
128 * @throws JsonSyntaxException
129 * @throws OpenWeatherMapCommunicationException
130 * @throws OpenWeatherMapConfigurationException
132 public synchronized @Nullable OpenWeatherMapJsonHourlyForecastData getHourlyForecastData(
133 @Nullable PointType location, int count)
134 throws JsonSyntaxException, OpenWeatherMapCommunicationException, OpenWeatherMapConfigurationException {
136 throw new OpenWeatherMapConfigurationException("@text/offline.conf-error-not-supported-number-of-hours");
139 Map<String, String> params = getRequestParams(handler.getOpenWeatherMapAPIConfig(), location);
140 params.put(PARAM_FORECAST_CNT, Integer.toString(count));
142 return gson.fromJson(getResponseFromCache(buildURL(THREE_HOUR_FORECAST_URL, params)),
143 OpenWeatherMapJsonHourlyForecastData.class);
147 * Requests the daily forecast data for the given location (see https://openweathermap.org/forecast16).
149 * @param location location represented as {@link PointType}
150 * @param count number of days
151 * @return the daily forecast data
152 * @throws JsonSyntaxException
153 * @throws OpenWeatherMapCommunicationException
154 * @throws OpenWeatherMapConfigurationException
156 public synchronized @Nullable OpenWeatherMapJsonDailyForecastData getDailyForecastData(@Nullable PointType location,
158 throws JsonSyntaxException, OpenWeatherMapCommunicationException, OpenWeatherMapConfigurationException {
160 throw new OpenWeatherMapConfigurationException("@text/offline.conf-error-not-supported-number-of-days");
163 Map<String, String> params = getRequestParams(handler.getOpenWeatherMapAPIConfig(), location);
164 params.put(PARAM_FORECAST_CNT, Integer.toString(count));
166 return gson.fromJson(getResponseFromCache(buildURL(DAILY_FORECAST_URL, params)),
167 OpenWeatherMapJsonDailyForecastData.class);
171 * Requests the UV Index data for the given location (see https://api.openweathermap.org/data/2.5/uvi).
173 * @param location location represented as {@link PointType}
174 * @return the UV Index data
175 * @throws JsonSyntaxException
176 * @throws OpenWeatherMapCommunicationException
177 * @throws OpenWeatherMapConfigurationException
179 public synchronized @Nullable OpenWeatherMapJsonUVIndexData getUVIndexData(@Nullable PointType location)
180 throws JsonSyntaxException, OpenWeatherMapCommunicationException, OpenWeatherMapConfigurationException {
181 return gson.fromJson(
182 getResponseFromCache(
183 buildURL(UVINDEX_URL, getRequestParams(handler.getOpenWeatherMapAPIConfig(), location))),
184 OpenWeatherMapJsonUVIndexData.class);
188 * Requests the UV Index forecast data for the given location (see https://api.openweathermap.org/data/2.5/uvi).
190 * @param location location represented as {@link PointType}
191 * @return the UV Index forecast data
192 * @throws JsonSyntaxException
193 * @throws OpenWeatherMapCommunicationException
194 * @throws OpenWeatherMapConfigurationException
196 public synchronized @Nullable List<OpenWeatherMapJsonUVIndexData> getUVIndexForecastData(
197 @Nullable PointType location, int count)
198 throws JsonSyntaxException, OpenWeatherMapCommunicationException, OpenWeatherMapConfigurationException {
200 throw new OpenWeatherMapConfigurationException(
201 "@text/offline.conf-error-not-supported-uvindex-number-of-days");
204 Map<String, String> params = getRequestParams(handler.getOpenWeatherMapAPIConfig(), location);
205 params.put(PARAM_FORECAST_CNT, Integer.toString(count));
207 return Arrays.asList(gson.fromJson(getResponseFromCache(buildURL(UVINDEX_FORECAST_URL, params)),
208 OpenWeatherMapJsonUVIndexData[].class));
212 * Downloads the icon for the given icon id (see https://openweathermap.org/weather-conditions).
214 * @param iconId the id of the icon
215 * @return the weather icon as {@link RawType}
217 public static @Nullable RawType getWeatherIcon(String iconId) {
218 if (iconId.isEmpty()) {
219 throw new IllegalArgumentException("Cannot download weather icon as icon id is null.");
222 return downloadWeatherIconFromCache(String.format(ICON_URL, iconId));
225 private static @Nullable RawType downloadWeatherIconFromCache(String url) {
226 if (IMAGE_CACHE.containsKey(url)) {
227 return new RawType(IMAGE_CACHE.get(url), PNG_CONTENT_TYPE);
229 RawType image = downloadWeatherIcon(url);
231 IMAGE_CACHE.put(url, image.getBytes());
238 private static @Nullable RawType downloadWeatherIcon(String url) {
239 return HttpUtil.downloadImage(url);
242 private Map<String, String> getRequestParams(OpenWeatherMapAPIConfiguration config, @Nullable PointType location) {
243 if (location == null) {
244 throw new OpenWeatherMapConfigurationException("@text/offline.conf-error-missing-location");
247 Map<String, String> params = new HashMap<>();
248 // API key (see http://openweathermap.org/appid)
249 String apikey = config.apikey;
250 if (apikey == null || (apikey = apikey.trim()).isEmpty()) {
251 throw new OpenWeatherMapConfigurationException("@text/offline.conf-error-missing-apikey");
253 params.put(PARAM_APPID, apikey);
255 // Units format (see https://openweathermap.org/current#data)
256 params.put(PARAM_UNITS, "metric");
258 // By geographic coordinates (see https://openweathermap.org/current#geo)
259 params.put(PARAM_LAT, location.getLatitude().toString());
260 params.put(PARAM_LON, location.getLongitude().toString());
262 // Multilingual support (see https://openweathermap.org/current#multi)
263 String language = config.language;
264 if (language != null && !(language = language.trim()).isEmpty()) {
265 params.put(PARAM_LANG, language.toLowerCase());
270 private String buildURL(String url, Map<String, String> requestParams) {
271 return requestParams.keySet().stream().map(key -> key + "=" + encodeParam(requestParams.get(key)))
272 .collect(joining("&", url + "?", ""));
275 private String encodeParam(String value) {
277 return URLEncoder.encode(value, StandardCharsets.UTF_8.name());
278 } catch (UnsupportedEncodingException e) {
279 logger.debug("UnsupportedEncodingException occurred during execution: {}", e.getLocalizedMessage(), e);
284 private @Nullable String getResponseFromCache(String url) {
285 return cache.putIfAbsentAndGet(url, () -> getResponse(url));
288 private String getResponse(String url) {
290 if (logger.isTraceEnabled()) {
291 logger.trace("OpenWeatherMap request: URL = '{}'", uglifyApikey(url));
293 ContentResponse contentResponse = httpClient.newRequest(url).method(GET).timeout(10, TimeUnit.SECONDS)
295 int httpStatus = contentResponse.getStatus();
296 String content = contentResponse.getContentAsString();
297 String errorMessage = "";
298 logger.trace("OpenWeatherMap response: status = {}, content = '{}'", httpStatus, content);
299 switch (httpStatus) {
302 case BAD_REQUEST_400:
303 case UNAUTHORIZED_401:
305 errorMessage = getErrorMessage(content);
306 logger.debug("OpenWeatherMap server responded with status code {}: {}", httpStatus, errorMessage);
307 throw new OpenWeatherMapConfigurationException(errorMessage);
308 case TOO_MANY_REQUESTS_429:
309 // TODO disable refresh job temporarily (see https://openweathermap.org/appid#Accesslimitation)
311 errorMessage = getErrorMessage(content);
312 logger.debug("OpenWeatherMap server responded with status code {}: {}", httpStatus, errorMessage);
313 throw new OpenWeatherMapCommunicationException(errorMessage);
315 } catch (ExecutionException e) {
316 String errorMessage = e.getLocalizedMessage();
317 logger.trace("Exception occurred during execution: {}", errorMessage, e);
318 if (e.getCause() instanceof HttpResponseException) {
319 logger.debug("OpenWeatherMap server responded with status code {}: Invalid API key.", UNAUTHORIZED_401);
320 throw new OpenWeatherMapConfigurationException("@text/offline.conf-error-invalid-apikey", e.getCause());
322 throw new OpenWeatherMapCommunicationException(errorMessage, e.getCause());
324 } catch (InterruptedException | TimeoutException e) {
325 logger.debug("Exception occurred during execution: {}", e.getLocalizedMessage(), e);
326 throw new OpenWeatherMapCommunicationException(e.getLocalizedMessage(), e.getCause());
330 private String uglifyApikey(String url) {
331 return url.replaceAll("(appid=)+\\w+", "appid=*****");
334 private String getErrorMessage(String response) {
335 JsonElement jsonResponse = parser.parse(response);
336 if (jsonResponse.isJsonObject()) {
337 JsonObject json = jsonResponse.getAsJsonObject();
338 if (json.has(PROPERTY_MESSAGE)) {
339 return json.get(PROPERTY_MESSAGE).getAsString();