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.darksky.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.HashMap;
23 import java.util.Locale;
25 import java.util.concurrent.ExecutionException;
26 import java.util.concurrent.TimeUnit;
27 import java.util.concurrent.TimeoutException;
29 import org.eclipse.jdt.annotation.NonNullByDefault;
30 import org.eclipse.jdt.annotation.Nullable;
31 import org.eclipse.jetty.client.HttpClient;
32 import org.eclipse.jetty.client.HttpResponseException;
33 import org.eclipse.jetty.client.api.ContentResponse;
34 import org.openhab.binding.darksky.internal.config.DarkSkyAPIConfiguration;
35 import org.openhab.binding.darksky.internal.handler.DarkSkyAPIHandler;
36 import org.openhab.binding.darksky.internal.model.DarkSkyJsonWeatherData;
37 import org.openhab.binding.darksky.internal.utils.ByteArrayFileCache;
38 import org.openhab.core.cache.ExpiringCacheMap;
39 import org.openhab.core.io.net.http.HttpUtil;
40 import org.openhab.core.library.types.PointType;
41 import org.openhab.core.library.types.RawType;
42 import org.slf4j.Logger;
43 import org.slf4j.LoggerFactory;
45 import com.google.gson.Gson;
46 import com.google.gson.JsonSyntaxException;
49 * The {@link DarkSkyConnection} is responsible for handling the connections to Dark Sky API.
51 * @author Christoph Weitkamp - Initial contribution
54 public class DarkSkyConnection {
56 private final Logger logger = LoggerFactory.getLogger(DarkSkyConnection.class);
58 private static final String PNG_CONTENT_TYPE = "image/png";
60 private static final String PARAM_EXCLUDE = "exclude";
61 private static final String PARAM_UNITS = "units";
62 private static final String PARAM_LANG = "lang";
64 // Current weather data (see https://darksky.net/dev/docs#forecast-request)
65 private static final String WEATHER_URL = "https://api.darksky.net/forecast/%s/%f,%f";
66 // Weather icons (see https://darksky.net/dev/docs/faq#icons)
67 private static final String ICON_URL = "https://darksky.net/images/weather-icons/%s.png";
69 private final DarkSkyAPIHandler handler;
70 private final HttpClient httpClient;
72 private static final ByteArrayFileCache IMAGE_CACHE = new ByteArrayFileCache("org.openhab.binding.darksky");
73 private final ExpiringCacheMap<String, String> cache;
75 private final Gson gson = new Gson();
77 public DarkSkyConnection(DarkSkyAPIHandler handler, HttpClient httpClient) {
78 this.handler = handler;
79 this.httpClient = httpClient;
81 DarkSkyAPIConfiguration config = handler.getDarkSkyAPIConfig();
82 cache = new ExpiringCacheMap<>(TimeUnit.MINUTES.toMillis(config.refreshInterval));
86 * Requests the current weather data for the given location (see https://darksky.net/dev/docs#forecast-request).
88 * @param location location represented as {@link PointType}
89 * @return the current weather data
90 * @throws JsonSyntaxException
91 * @throws DarkSkyCommunicationException
92 * @throws DarkSkyConfigurationException
94 public synchronized @Nullable DarkSkyJsonWeatherData getWeatherData(@Nullable PointType location)
95 throws JsonSyntaxException, DarkSkyCommunicationException, DarkSkyConfigurationException {
96 if (location == null) {
97 throw new DarkSkyConfigurationException("@text/offline.conf-error-missing-location");
100 DarkSkyAPIConfiguration config = handler.getDarkSkyAPIConfig();
101 String apikey = config.apikey;
102 if (apikey == null || (apikey = apikey.trim()).isEmpty()) {
103 throw new DarkSkyConfigurationException("@text/offline.conf-error-missing-apikey");
106 String url = String.format(Locale.ROOT, WEATHER_URL, apikey, location.getLatitude().doubleValue(),
107 location.getLongitude().doubleValue());
109 return gson.fromJson(getResponseFromCache(buildURL(url, getRequestParams(config))),
110 DarkSkyJsonWeatherData.class);
114 * Downloads the icon for the given icon id (see https://darksky.net/dev/docs/faq#icons).
116 * @param iconId the id of the icon
117 * @return the weather icon as {@link RawType}
119 public static @Nullable RawType getWeatherIcon(String iconId) {
120 if (iconId.isEmpty()) {
121 throw new IllegalArgumentException("Cannot download weather icon as icon id is null.");
124 return downloadWeatherIconFromCache(String.format(ICON_URL, iconId));
127 private static @Nullable RawType downloadWeatherIconFromCache(String url) {
128 if (IMAGE_CACHE.containsKey(url)) {
129 return new RawType(IMAGE_CACHE.get(url), PNG_CONTENT_TYPE);
131 RawType image = downloadWeatherIcon(url);
133 IMAGE_CACHE.put(url, image.getBytes());
140 private static @Nullable RawType downloadWeatherIcon(String url) {
141 return HttpUtil.downloadImage(url);
144 private Map<String, String> getRequestParams(DarkSkyAPIConfiguration config) {
145 Map<String, String> params = new HashMap<>();
146 params.put(PARAM_EXCLUDE, "minutely,flags");
148 // params.put(PARAM_EXTEND, "hourly");
150 params.put(PARAM_UNITS, "si");
152 String language = config.language;
153 if (language != null && !(language = language.trim()).isEmpty()) {
154 params.put(PARAM_LANG, language.toLowerCase());
159 private String buildURL(String url, Map<String, String> requestParams) {
160 return requestParams.keySet().stream().map(key -> key + "=" + encodeParam(requestParams.get(key)))
161 .collect(joining("&", url + "?", ""));
164 private String encodeParam(String value) {
166 return URLEncoder.encode(value, StandardCharsets.UTF_8.name());
167 } catch (UnsupportedEncodingException e) {
168 logger.debug("UnsupportedEncodingException occurred during execution: {}", e.getLocalizedMessage(), e);
173 private @Nullable String getResponseFromCache(String url) {
174 return cache.putIfAbsentAndGet(url, () -> getResponse(url));
177 private String getResponse(String url) {
179 if (logger.isTraceEnabled()) {
180 logger.trace("Dark Sky request: URL = '{}'", uglifyApikey(url));
182 ContentResponse contentResponse = httpClient.newRequest(url).method(GET).timeout(10, TimeUnit.SECONDS)
184 int httpStatus = contentResponse.getStatus();
185 String content = contentResponse.getContentAsString();
186 logger.trace("Dark Sky response: status = {}, content = '{}'", httpStatus, content);
187 switch (httpStatus) {
190 case BAD_REQUEST_400:
191 case UNAUTHORIZED_401:
193 logger.debug("Dark Sky server responded with status code {}: {}", httpStatus, content);
194 throw new DarkSkyConfigurationException(content);
196 logger.debug("Dark Sky server responded with status code {}: {}", httpStatus, content);
197 throw new DarkSkyCommunicationException(content);
199 } catch (ExecutionException e) {
200 String errorMessage = e.getLocalizedMessage();
201 logger.trace("Exception occurred during execution: {}", errorMessage, e);
202 if (e.getCause() instanceof HttpResponseException) {
203 logger.debug("Dark Sky server responded with status code {}: Invalid API key.", UNAUTHORIZED_401);
204 throw new DarkSkyConfigurationException("@text/offline.conf-error-invalid-apikey", e.getCause());
206 throw new DarkSkyCommunicationException(errorMessage, e.getCause());
208 } catch (InterruptedException | TimeoutException e) {
209 logger.debug("Exception occurred during execution: {}", e.getLocalizedMessage(), e);
210 throw new DarkSkyCommunicationException(e.getLocalizedMessage(), e.getCause());
214 private String uglifyApikey(String url) {
215 return url.replaceAll("(appid=)+\\w+", "appid=*****");