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.IOException;
20 import java.io.UnsupportedEncodingException;
21 import java.net.URLEncoder;
22 import java.nio.charset.StandardCharsets;
23 import java.util.Arrays;
24 import java.util.HashMap;
25 import java.util.List;
27 import java.util.concurrent.ExecutionException;
28 import java.util.concurrent.TimeUnit;
29 import java.util.concurrent.TimeoutException;
31 import org.eclipse.jdt.annotation.NonNullByDefault;
32 import org.eclipse.jdt.annotation.Nullable;
33 import org.eclipse.jetty.client.HttpClient;
34 import org.eclipse.jetty.client.HttpResponseException;
35 import org.eclipse.jetty.client.api.ContentResponse;
36 import org.openhab.binding.openweathermap.internal.config.OpenWeatherMapAPIConfiguration;
37 import org.openhab.binding.openweathermap.internal.dto.OpenWeatherMapJsonDailyForecastData;
38 import org.openhab.binding.openweathermap.internal.dto.OpenWeatherMapJsonHourlyForecastData;
39 import org.openhab.binding.openweathermap.internal.dto.OpenWeatherMapJsonUVIndexData;
40 import org.openhab.binding.openweathermap.internal.dto.OpenWeatherMapJsonWeatherData;
41 import org.openhab.binding.openweathermap.internal.handler.OpenWeatherMapAPIHandler;
42 import org.openhab.core.cache.ByteArrayFileCache;
43 import org.openhab.core.cache.ExpiringCacheMap;
44 import org.openhab.core.io.net.http.HttpUtil;
45 import org.openhab.core.library.types.PointType;
46 import org.openhab.core.library.types.RawType;
47 import org.slf4j.Logger;
48 import org.slf4j.LoggerFactory;
50 import com.google.gson.Gson;
51 import com.google.gson.JsonElement;
52 import com.google.gson.JsonObject;
53 import com.google.gson.JsonParser;
54 import com.google.gson.JsonSyntaxException;
57 * The {@link OpenWeatherMapConnection} is responsible for handling the connections to OpenWeatherMap API.
59 * @author Christoph Weitkamp - Initial contribution
62 public class OpenWeatherMapConnection {
64 private final Logger logger = LoggerFactory.getLogger(OpenWeatherMapConnection.class);
66 private static final String PROPERTY_MESSAGE = "message";
68 private static final String PNG_CONTENT_TYPE = "image/png";
70 private static final String PARAM_APPID = "appid";
71 private static final String PARAM_UNITS = "units";
72 private static final String PARAM_LAT = "lat";
73 private static final String PARAM_LON = "lon";
74 private static final String PARAM_LANG = "lang";
75 private static final String PARAM_FORECAST_CNT = "cnt";
77 // Current weather data (see https://openweathermap.org/current)
78 private static final String WEATHER_URL = "https://api.openweathermap.org/data/2.5/weather";
79 // 5 day / 3 hour forecast (see https://openweathermap.org/forecast5)
80 private static final String THREE_HOUR_FORECAST_URL = "https://api.openweathermap.org/data/2.5/forecast";
81 // 16 day / daily forecast (see https://openweathermap.org/forecast16)
82 private static final String DAILY_FORECAST_URL = "https://api.openweathermap.org/data/2.5/forecast/daily";
83 // UV Index (see https://openweathermap.org/api/uvi)
84 private static final String UVINDEX_URL = "https://api.openweathermap.org/data/2.5/uvi";
85 private static final String UVINDEX_FORECAST_URL = "https://api.openweathermap.org/data/2.5/uvi/forecast";
86 // Weather icons (see https://openweathermap.org/weather-conditions)
87 private static final String ICON_URL = "https://openweathermap.org/img/w/%s.png";
89 private final OpenWeatherMapAPIHandler handler;
90 private final HttpClient httpClient;
92 private static final ByteArrayFileCache IMAGE_CACHE = new ByteArrayFileCache("org.openhab.binding.openweathermap");
93 private final ExpiringCacheMap<String, String> cache;
95 private final JsonParser parser = new JsonParser();
96 private final Gson gson = new Gson();
98 public OpenWeatherMapConnection(OpenWeatherMapAPIHandler handler, HttpClient httpClient) {
99 this.handler = handler;
100 this.httpClient = httpClient;
102 OpenWeatherMapAPIConfiguration config = handler.getOpenWeatherMapAPIConfig();
103 cache = new ExpiringCacheMap<>(TimeUnit.MINUTES.toMillis(config.refreshInterval));
107 * Requests the current weather data for the given location (see https://openweathermap.org/current).
109 * @param location location represented as {@link PointType}
110 * @return the current weather data
111 * @throws JsonSyntaxException
112 * @throws OpenWeatherMapCommunicationException
113 * @throws OpenWeatherMapConfigurationException
115 public synchronized @Nullable OpenWeatherMapJsonWeatherData getWeatherData(@Nullable PointType location)
116 throws JsonSyntaxException, OpenWeatherMapCommunicationException, OpenWeatherMapConfigurationException {
117 return gson.fromJson(
118 getResponseFromCache(
119 buildURL(WEATHER_URL, getRequestParams(handler.getOpenWeatherMapAPIConfig(), location))),
120 OpenWeatherMapJsonWeatherData.class);
124 * Requests the hourly forecast data for the given location (see https://openweathermap.org/forecast5).
126 * @param location location represented as {@link PointType}
127 * @param count number of hours
128 * @return the hourly forecast data
129 * @throws JsonSyntaxException
130 * @throws OpenWeatherMapCommunicationException
131 * @throws OpenWeatherMapConfigurationException
133 public synchronized @Nullable OpenWeatherMapJsonHourlyForecastData getHourlyForecastData(
134 @Nullable PointType location, int count)
135 throws JsonSyntaxException, OpenWeatherMapCommunicationException, OpenWeatherMapConfigurationException {
137 throw new OpenWeatherMapConfigurationException("@text/offline.conf-error-not-supported-number-of-hours");
140 Map<String, String> params = getRequestParams(handler.getOpenWeatherMapAPIConfig(), location);
141 params.put(PARAM_FORECAST_CNT, Integer.toString(count));
143 return gson.fromJson(getResponseFromCache(buildURL(THREE_HOUR_FORECAST_URL, params)),
144 OpenWeatherMapJsonHourlyForecastData.class);
148 * Requests the daily forecast data for the given location (see https://openweathermap.org/forecast16).
150 * @param location location represented as {@link PointType}
151 * @param count number of days
152 * @return the daily forecast data
153 * @throws JsonSyntaxException
154 * @throws OpenWeatherMapCommunicationException
155 * @throws OpenWeatherMapConfigurationException
157 public synchronized @Nullable OpenWeatherMapJsonDailyForecastData getDailyForecastData(@Nullable PointType location,
159 throws JsonSyntaxException, OpenWeatherMapCommunicationException, OpenWeatherMapConfigurationException {
161 throw new OpenWeatherMapConfigurationException("@text/offline.conf-error-not-supported-number-of-days");
164 Map<String, String> params = getRequestParams(handler.getOpenWeatherMapAPIConfig(), location);
165 params.put(PARAM_FORECAST_CNT, Integer.toString(count));
167 return gson.fromJson(getResponseFromCache(buildURL(DAILY_FORECAST_URL, params)),
168 OpenWeatherMapJsonDailyForecastData.class);
172 * Requests the UV Index data for the given location (see https://api.openweathermap.org/data/2.5/uvi).
174 * @param location location represented as {@link PointType}
175 * @return the UV Index data
176 * @throws JsonSyntaxException
177 * @throws OpenWeatherMapCommunicationException
178 * @throws OpenWeatherMapConfigurationException
180 public synchronized @Nullable OpenWeatherMapJsonUVIndexData getUVIndexData(@Nullable PointType location)
181 throws JsonSyntaxException, OpenWeatherMapCommunicationException, OpenWeatherMapConfigurationException {
182 return gson.fromJson(
183 getResponseFromCache(
184 buildURL(UVINDEX_URL, getRequestParams(handler.getOpenWeatherMapAPIConfig(), location))),
185 OpenWeatherMapJsonUVIndexData.class);
189 * Requests the UV Index forecast data for the given location (see https://api.openweathermap.org/data/2.5/uvi).
191 * @param location location represented as {@link PointType}
192 * @return the UV Index forecast data
193 * @throws JsonSyntaxException
194 * @throws OpenWeatherMapCommunicationException
195 * @throws OpenWeatherMapConfigurationException
197 public synchronized @Nullable List<OpenWeatherMapJsonUVIndexData> getUVIndexForecastData(
198 @Nullable PointType location, int count)
199 throws JsonSyntaxException, OpenWeatherMapCommunicationException, OpenWeatherMapConfigurationException {
201 throw new OpenWeatherMapConfigurationException(
202 "@text/offline.conf-error-not-supported-uvindex-number-of-days");
205 Map<String, String> params = getRequestParams(handler.getOpenWeatherMapAPIConfig(), location);
206 params.put(PARAM_FORECAST_CNT, Integer.toString(count));
208 return Arrays.asList(gson.fromJson(getResponseFromCache(buildURL(UVINDEX_FORECAST_URL, params)),
209 OpenWeatherMapJsonUVIndexData[].class));
213 * Downloads the icon for the given icon id (see https://openweathermap.org/weather-conditions).
215 * @param iconId the id of the icon
216 * @return the weather icon as {@link RawType}
218 public static @Nullable RawType getWeatherIcon(String iconId) {
219 if (iconId.isEmpty()) {
220 throw new IllegalArgumentException("Cannot download weather icon as icon id is null.");
223 return downloadWeatherIconFromCache(String.format(ICON_URL, iconId));
226 private static @Nullable RawType downloadWeatherIconFromCache(String url) {
227 if (IMAGE_CACHE.containsKey(url)) {
229 return new RawType(IMAGE_CACHE.get(url), PNG_CONTENT_TYPE);
230 } catch (IOException e) {
231 LoggerFactory.getLogger(OpenWeatherMapConnection.class)
232 .trace("Failed to download the content of URL '{}'", url, e);
235 RawType image = downloadWeatherIcon(url);
237 IMAGE_CACHE.put(url, image.getBytes());
244 private static @Nullable RawType downloadWeatherIcon(String url) {
245 return HttpUtil.downloadImage(url);
248 private Map<String, String> getRequestParams(OpenWeatherMapAPIConfiguration config, @Nullable PointType location) {
249 if (location == null) {
250 throw new OpenWeatherMapConfigurationException("@text/offline.conf-error-missing-location");
253 Map<String, String> params = new HashMap<>();
254 // API key (see http://openweathermap.org/appid)
255 String apikey = config.apikey;
256 if (apikey == null || (apikey = apikey.trim()).isEmpty()) {
257 throw new OpenWeatherMapConfigurationException("@text/offline.conf-error-missing-apikey");
259 params.put(PARAM_APPID, apikey);
261 // Units format (see https://openweathermap.org/current#data)
262 params.put(PARAM_UNITS, "metric");
264 // By geographic coordinates (see https://openweathermap.org/current#geo)
265 params.put(PARAM_LAT, location.getLatitude().toString());
266 params.put(PARAM_LON, location.getLongitude().toString());
268 // Multilingual support (see https://openweathermap.org/current#multi)
269 String language = config.language;
270 if (language != null && !(language = language.trim()).isEmpty()) {
271 params.put(PARAM_LANG, language.toLowerCase());
276 private String buildURL(String url, Map<String, String> requestParams) {
277 return requestParams.keySet().stream().map(key -> key + "=" + encodeParam(requestParams.get(key)))
278 .collect(joining("&", url + "?", ""));
281 private String encodeParam(@Nullable String value) {
286 return URLEncoder.encode(value, StandardCharsets.UTF_8.name());
287 } catch (UnsupportedEncodingException e) {
288 logger.debug("UnsupportedEncodingException occurred during execution: {}", e.getLocalizedMessage(), e);
293 private @Nullable String getResponseFromCache(String url) {
294 return cache.putIfAbsentAndGet(url, () -> getResponse(url));
297 private String getResponse(String url) {
299 if (logger.isTraceEnabled()) {
300 logger.trace("OpenWeatherMap request: URL = '{}'", uglifyApikey(url));
302 ContentResponse contentResponse = httpClient.newRequest(url).method(GET).timeout(10, TimeUnit.SECONDS)
304 int httpStatus = contentResponse.getStatus();
305 String content = contentResponse.getContentAsString();
306 String errorMessage = "";
307 logger.trace("OpenWeatherMap response: status = {}, content = '{}'", httpStatus, content);
308 switch (httpStatus) {
311 case BAD_REQUEST_400:
312 case UNAUTHORIZED_401:
314 errorMessage = getErrorMessage(content);
315 logger.debug("OpenWeatherMap server responded with status code {}: {}", httpStatus, errorMessage);
316 throw new OpenWeatherMapConfigurationException(errorMessage);
317 case TOO_MANY_REQUESTS_429:
318 // TODO disable refresh job temporarily (see https://openweathermap.org/appid#Accesslimitation)
320 errorMessage = getErrorMessage(content);
321 logger.debug("OpenWeatherMap server responded with status code {}: {}", httpStatus, errorMessage);
322 throw new OpenWeatherMapCommunicationException(errorMessage);
324 } catch (ExecutionException e) {
325 String errorMessage = e.getLocalizedMessage();
326 logger.trace("Exception occurred during execution: {}", errorMessage, e);
327 if (e.getCause() instanceof HttpResponseException) {
328 logger.debug("OpenWeatherMap server responded with status code {}: Invalid API key.", UNAUTHORIZED_401);
329 throw new OpenWeatherMapConfigurationException("@text/offline.conf-error-invalid-apikey", e.getCause());
331 throw new OpenWeatherMapCommunicationException(errorMessage, e.getCause());
333 } catch (InterruptedException | TimeoutException e) {
334 logger.debug("Exception occurred during execution: {}", e.getLocalizedMessage(), e);
335 throw new OpenWeatherMapCommunicationException(e.getLocalizedMessage(), e.getCause());
339 private String uglifyApikey(String url) {
340 return url.replaceAll("(appid=)+\\w+", "appid=*****");
343 private String getErrorMessage(String response) {
344 JsonElement jsonResponse = parser.parse(response);
345 if (jsonResponse.isJsonObject()) {
346 JsonObject json = jsonResponse.getAsJsonObject();
347 if (json.has(PROPERTY_MESSAGE)) {
348 return json.get(PROPERTY_MESSAGE).getAsString();