2 * Copyright (c) 2010-2023 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.weathercompany.internal.handler;
15 import static org.openhab.binding.weathercompany.internal.WeatherCompanyBindingConstants.*;
17 import java.awt.image.BufferedImage;
18 import java.io.ByteArrayOutputStream;
20 import java.io.IOException;
21 import java.io.InputStream;
23 import java.util.Objects;
24 import java.util.concurrent.Future;
25 import java.util.concurrent.TimeUnit;
27 import javax.imageio.ImageIO;
29 import org.eclipse.jdt.annotation.NonNullByDefault;
30 import org.eclipse.jdt.annotation.Nullable;
31 import org.eclipse.jetty.client.HttpClient;
32 import org.openhab.binding.weathercompany.internal.config.WeatherCompanyForecastConfig;
33 import org.openhab.binding.weathercompany.internal.model.DayPartDTO;
34 import org.openhab.binding.weathercompany.internal.model.ForecastDTO;
35 import org.openhab.core.i18n.LocaleProvider;
36 import org.openhab.core.i18n.TimeZoneProvider;
37 import org.openhab.core.i18n.UnitProvider;
38 import org.openhab.core.library.types.RawType;
39 import org.openhab.core.library.unit.Units;
40 import org.openhab.core.thing.ChannelUID;
41 import org.openhab.core.thing.Thing;
42 import org.openhab.core.thing.ThingStatus;
43 import org.openhab.core.thing.ThingStatusDetail;
44 import org.openhab.core.types.Command;
45 import org.openhab.core.types.RefreshType;
46 import org.openhab.core.types.State;
47 import org.openhab.core.types.UnDefType;
48 import org.osgi.framework.FrameworkUtil;
49 import org.slf4j.Logger;
50 import org.slf4j.LoggerFactory;
52 import com.google.gson.JsonSyntaxException;
55 * The {@link WeatherCompanyForecastHandler} is responsible for pulling weather forecast
56 * information from the Weather Company API.
58 * API documentation is located here
59 * - https://docs.google.com/document/d/1eKCnKXI9xnoMGRRzOL1xPCBihNV2rOet08qpE_gArAY/edit
61 * @author Mark Hilbush - Initial contribution
64 public class WeatherCompanyForecastHandler extends WeatherCompanyAbstractHandler {
65 private static final String BASE_FORECAST_URL = "https://api.weather.com/v3/wx/forecast/daily/5day";
67 private final Logger logger = LoggerFactory.getLogger(WeatherCompanyForecastHandler.class);
69 private final LocaleProvider localeProvider;
71 private int refreshIntervalSeconds;
72 private String locationQueryString = "";
73 private String languageQueryString = "";
75 private @Nullable Future<?> refreshForecastJob;
77 private final Runnable refreshRunnable = new Runnable() {
84 public WeatherCompanyForecastHandler(Thing thing, TimeZoneProvider timeZoneProvider, HttpClient httpClient,
85 UnitProvider unitProvider, LocaleProvider localeProvider) {
86 super(thing, timeZoneProvider, httpClient, unitProvider);
87 this.localeProvider = localeProvider;
91 public void initialize() {
92 logger.debug("Forecast handler initializing with configuration: {}",
93 getConfigAs(WeatherCompanyForecastConfig.class).toString());
95 refreshIntervalSeconds = getConfigAs(WeatherCompanyForecastConfig.class).refreshInterval * 60;
96 if (isValidLocation()) {
97 weatherDataCache.clear();
100 updateStatus(isBridgeOnline() ? ThingStatus.ONLINE : ThingStatus.OFFLINE);
105 public void dispose() {
107 updateStatus(ThingStatus.OFFLINE);
111 public void handleCommand(ChannelUID channelUID, Command command) {
112 if (command.equals(RefreshType.REFRESH)) {
113 State state = weatherDataCache.get(channelUID.getId());
115 updateChannel(channelUID.getId(), state);
120 private boolean isValidLocation() {
121 boolean validLocation = false;
122 WeatherCompanyForecastConfig config = getConfigAs(WeatherCompanyForecastConfig.class);
123 String locationType = config.locationType;
124 if (locationType == null) {
125 return validLocation;
127 switch (locationType) {
128 case CONFIG_LOCATION_TYPE_POSTAL_CODE:
129 String postalCode = config.postalCode;
130 if (postalCode == null || postalCode.isBlank()) {
131 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
132 "@text/offline.config-error-unset-postal-code");
134 locationQueryString = "&postalKey=" + postalCode.replace(" ", "");
135 validLocation = true;
138 case CONFIG_LOCATION_TYPE_GEOCODE:
139 String geocode = config.geocode;
140 if (geocode == null || geocode.isBlank()) {
141 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
142 "@text/offline.config-error-unset-geocode");
144 locationQueryString = "&geocode=" + geocode.replace(" ", "");
145 validLocation = true;
148 case CONFIG_LOCATION_TYPE_IATA_CODE:
149 String iataCode = config.iataCode;
150 if (iataCode == null || iataCode.isBlank()) {
151 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
152 "@text/offline.config-error-unset-iata-code");
154 locationQueryString = "&iataCode=" + iataCode.replace(" ", "").toUpperCase();
155 validLocation = true;
159 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
160 "@text/offline.config-error-unset-location-type");
163 return validLocation;
166 private void setLanguage() {
167 WeatherCompanyForecastConfig config = getConfigAs(WeatherCompanyForecastConfig.class);
168 String language = config.language;
169 if (language == null || language.isBlank()) {
170 // Nothing in the thing config, so try to get a match from the openHAB locale
171 String derivedLanguage = WeatherCompanyAbstractHandler.lookupLanguage(localeProvider.getLocale());
172 languageQueryString = "&language=" + derivedLanguage;
173 logger.debug("Language not set in thing config, using {}", derivedLanguage);
175 // Use what is set in the thing config
176 languageQueryString = "&language=" + language.trim();
181 * Build the URL for requesting the 5-day forecast. It's important to request
182 * the desired language AND units so that the forecast narrative contains
183 * the consistent language and units (e.g. wind gusts to 30 mph).
185 private String buildForecastUrl() {
186 String apiKey = getApiKey();
187 StringBuilder sb = new StringBuilder(BASE_FORECAST_URL);
188 // Set response type as JSON
189 sb.append("?format=json");
190 // Set language from config
191 sb.append(languageQueryString);
192 // Set API key from config
193 sb.append("&apiKey=").append(apiKey);
194 // Set the units to Imperial or Metric
195 sb.append("&units=").append(getUnitsQueryString());
196 // Set the location from config
197 sb.append(locationQueryString);
198 String url = sb.toString();
199 logger.debug("Forecast URL is {}", url.replace(apiKey, REPLACE_API_KEY));
200 return url.toString();
203 private synchronized void refreshForecast() {
204 if (!isBridgeOnline()) {
205 // If bridge is not online, API has not been validated yet
206 logger.debug("Handler: Can't refresh forecast because bridge is not online");
209 logger.debug("Handler: Requesting forecast from The Weather Company API");
210 String response = executeApiRequest(buildForecastUrl());
211 if (response == null) {
215 logger.trace("Handler: Parsing forecast response: {}", response);
216 ForecastDTO forecast = Objects.requireNonNull(gson.fromJson(response, ForecastDTO.class));
217 logger.debug("Handler: Successfully parsed daily forecast response object");
218 updateStatus(ThingStatus.ONLINE);
219 updateDailyForecast(forecast);
220 updateDaypartForecast(forecast.daypart);
221 } catch (JsonSyntaxException e) {
222 logger.debug("Handler: Error parsing daily forecast response object", e);
223 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
224 "@text/offline.comm-error-parsing-daily-forecast");
229 private void updateDailyForecast(ForecastDTO forecast) {
230 for (int day = 0; day < forecast.dayOfWeek.length; day++) {
231 logger.debug("Processing daily forecast for '{}'", forecast.dayOfWeek[day]);
232 updateDaily(day, CH_DAY_OF_WEEK, undefOrString(forecast.dayOfWeek[day]));
233 updateDaily(day, CH_NARRATIVE, undefOrString(forecast.narrative[day]));
234 updateDaily(day, CH_VALID_TIME_LOCAL, undefOrDate(forecast.validTimeUtc[day]));
235 updateDaily(day, CH_EXPIRATION_TIME_LOCAL, undefOrDate(forecast.expirationTimeUtc[day]));
236 updateDaily(day, CH_TEMP_MAX, undefOrQuantity(forecast.temperatureMax[day], getTempUnit()));
237 updateDaily(day, CH_TEMP_MIN, undefOrQuantity(forecast.temperatureMin[day], getTempUnit()));
238 updateDaily(day, CH_PRECIP_RAIN, undefOrQuantity(forecast.qpf[day], getLengthUnit()));
239 updateDaily(day, CH_PRECIP_SNOW, undefOrQuantity(forecast.qpfSnow[day], getLengthUnit()));
243 private void updateDaypartForecast(Object daypartObject) {
244 DayPartDTO[] dayparts;
246 String innerJson = gson.toJson(daypartObject);
247 logger.debug("Parsing daypartsObject: {}", innerJson);
248 dayparts = gson.fromJson(innerJson.toString(), DayPartDTO[].class);
249 logger.debug("Handler: Successfully parsed daypart forecast object");
250 } catch (JsonSyntaxException e) {
251 logger.debug("Handler: Error parsing daypart forecast object: {}", e.getMessage(), e);
252 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
253 "@text/offline.comm-error-parsing-daypart-forecast");
256 logger.debug("There are {} daypart forecast entries", dayparts.length);
257 if (dayparts.length == 0) {
258 logger.debug("There is no daypart forecast object in this message");
261 logger.debug("There are {} daypartName entries in this forecast", dayparts[0].daypartName.length);
262 for (int i = 0; i < dayparts[0].daypartName.length; i++) {
263 // Note: All dayparts[0] (i.e. today day) values are null after 3 pm local time
264 DayPartDTO dp = dayparts[0];
265 // Even daypart indexes are Day (D); odd daypart indexes are Night (N)
266 String dOrN = dp.dayOrNight[i] == null ? (i % 2 == 0 ? "D" : "N") : dp.dayOrNight[i];
267 logger.debug("Processing daypart forecast for '{}'", dp.daypartName[i]);
268 updateDaypart(i, dOrN, CH_DP_NAME, undefOrString(dp.daypartName[i]));
269 updateDaypart(i, dOrN, CH_DP_DAY_OR_NIGHT, undefOrString(dayparts[0].dayOrNight[i]));
270 updateDaypart(i, dOrN, CH_DP_NARRATIVE, undefOrString(dayparts[0].narrative[i]));
271 updateDaypart(i, dOrN, CH_DP_WX_PHRASE_SHORT, undefOrString(dayparts[0].wxPhraseShort[i]));
272 updateDaypart(i, dOrN, CH_DP_WX_PHRASE_LONG, undefOrString(dayparts[0].wxPhraseLong[i]));
273 updateDaypart(i, dOrN, CH_DP_QUALIFIER_PHRASE, undefOrString(dayparts[0].qualifierPhrase[i]));
274 updateDaypart(i, dOrN, CH_DP_QUALIFIER_CODE, undefOrString(dayparts[0].qualifierCode[i]));
275 updateDaypart(i, dOrN, CH_DP_TEMP, undefOrQuantity(dp.temperature[i], getTempUnit()));
276 updateDaypart(i, dOrN, CH_DP_TEMP_HEAT_INDEX, undefOrQuantity(dp.temperatureHeatIndex[i], getTempUnit()));
277 updateDaypart(i, dOrN, CH_DP_TEMP_WIND_CHILL, undefOrQuantity(dp.temperatureWindChill[i], getTempUnit()));
278 updateDaypart(i, dOrN, CH_DP_HUMIDITY, undefOrQuantity(dp.relativeHumidity[i], Units.PERCENT));
279 updateDaypart(i, dOrN, CH_DP_CLOUD_COVER, undefOrQuantity(dp.cloudCover[i], Units.PERCENT));
280 updateDaypart(i, dOrN, CH_DP_PRECIP_CHANCE, undefOrQuantity(dp.precipChance[i], Units.PERCENT));
281 updateDaypart(i, dOrN, CH_DP_PRECIP_TYPE, undefOrString(dp.precipType[i]));
282 updateDaypart(i, dOrN, CH_DP_PRECIP_RAIN, undefOrQuantity(dp.qpf[i], getLengthUnit()));
283 updateDaypart(i, dOrN, CH_DP_PRECIP_SNOW, undefOrQuantity(dp.qpfSnow[i], getLengthUnit()));
284 updateDaypart(i, dOrN, CH_DP_SNOW_RANGE, undefOrString(dp.snowRange[i]));
285 updateDaypart(i, dOrN, CH_DP_WIND_SPEED, undefOrQuantity(dp.windSpeed[i], getSpeedUnit()));
286 updateDaypart(i, dOrN, CH_DP_WIND_DIR_CARDINAL, undefOrString(dp.windDirectionCardinal[i]));
287 updateDaypart(i, dOrN, CH_DP_WIND_PHRASE, undefOrString(dp.windPhrase[i]));
288 updateDaypart(i, dOrN, CH_DP_WIND_DIR, undefOrQuantity(dp.windDirection[i], Units.DEGREE_ANGLE));
289 updateDaypart(i, dOrN, CH_DP_THUNDER_CATEGORY, undefOrString(dp.thunderCategory[i]));
290 updateDaypart(i, dOrN, CH_DP_THUNDER_INDEX, undefOrDecimal(dp.thunderIndex[i]));
291 updateDaypart(i, dOrN, CH_DP_UV_DESCRIPTION, undefOrString(dp.uvDescription[i]));
292 updateDaypart(i, dOrN, CH_DP_UV_INDEX, undefOrDecimal(dp.uvIndex[i]));
293 updateDaypart(i, dOrN, CH_DP_ICON_CODE, undefOrDecimal(dp.iconCode[i]));
294 updateDaypart(i, dOrN, CH_DP_ICON_CODE_EXTEND, undefOrDecimal(dp.iconCodeExtend[i]));
295 updateDaypart(i, dOrN, CH_DP_ICON_IMAGE, getIconImage(dp.iconCode[i]));
299 private State getIconImage(Integer iconCode) {
300 // First try to get the image associated with the icon code
301 byte[] image = getImage("icons" + File.separator + String.format("%02d", iconCode) + ".png");
303 return new RawType(image, "image/png");
305 // Next try to get the N/A image
306 image = getImage("icons" + File.separator + "na.png");
308 return new RawType(image, "image/png");
310 // Couldn't get any icon image, so set to UNDEF
311 return UnDefType.UNDEF;
314 private byte @Nullable [] getImage(String iconPath) {
316 URL url = FrameworkUtil.getBundle(getClass()).getResource(iconPath);
317 logger.trace("Path to icon image resource is: {}", url);
319 try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
320 InputStream is = url.openStream();
321 BufferedImage image = ImageIO.read(is);
322 ImageIO.write(image, "png", out);
324 data = out.toByteArray();
325 } catch (IOException e) {
326 logger.debug("I/O exception occurred getting image data: {}", e.getMessage(), e);
332 private void updateDaily(int day, String channelId, State state) {
333 updateChannel(CH_GROUP_FORECAST_DAY + String.valueOf(day) + "#" + channelId, state);
336 private void updateDaypart(int daypartIndex, String dayOrNight, String channelId, State state) {
337 int day = daypartIndex / 2;
338 String dON = dayOrNight.equals("D") ? CH_GROUP_FORECAST_DAYPART_DAY : CH_GROUP_FORECAST_DAYPART_NIGHT;
339 updateChannel(CH_GROUP_FORECAST_DAY + String.valueOf(day) + dON + "#" + channelId, state);
343 * The refresh job updates the daily forecast on the
344 * refresh interval set in the thing config
346 private void scheduleRefreshJob() {
347 logger.debug("Handler: Scheduling forecast refresh job in {} seconds", REFRESH_JOB_INITIAL_DELAY_SECONDS);
349 refreshForecastJob = scheduler.scheduleWithFixedDelay(refreshRunnable, REFRESH_JOB_INITIAL_DELAY_SECONDS,
350 refreshIntervalSeconds, TimeUnit.SECONDS);
353 private void cancelRefreshJob() {
354 if (refreshForecastJob != null) {
355 refreshForecastJob.cancel(true);
356 logger.debug("Handler: Canceling forecast refresh job");