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.meteoblue.internal.handler;
15 import static org.openhab.core.library.unit.MetricPrefix.*;
17 import java.awt.image.BufferedImage;
18 import java.io.ByteArrayOutputStream;
19 import java.io.IOException;
20 import java.math.BigDecimal;
21 import java.math.RoundingMode;
22 import java.time.ZoneId;
23 import java.time.ZonedDateTime;
24 import java.util.Calendar;
25 import java.util.concurrent.ScheduledFuture;
26 import java.util.concurrent.TimeUnit;
28 import javax.imageio.ImageIO;
30 import org.apache.commons.lang.StringUtils;
31 import org.openhab.binding.meteoblue.internal.Forecast;
32 import org.openhab.binding.meteoblue.internal.MeteoBlueConfiguration;
33 import org.openhab.binding.meteoblue.internal.json.JsonData;
34 import org.openhab.core.io.net.http.HttpUtil;
35 import org.openhab.core.library.items.ImageItem;
36 import org.openhab.core.library.types.DateTimeType;
37 import org.openhab.core.library.types.DecimalType;
38 import org.openhab.core.library.types.QuantityType;
39 import org.openhab.core.library.types.RawType;
40 import org.openhab.core.library.types.StringType;
41 import org.openhab.core.library.unit.SIUnits;
42 import org.openhab.core.library.unit.SmartHomeUnits;
43 import org.openhab.core.thing.Bridge;
44 import org.openhab.core.thing.Channel;
45 import org.openhab.core.thing.ChannelUID;
46 import org.openhab.core.thing.Thing;
47 import org.openhab.core.thing.ThingStatus;
48 import org.openhab.core.thing.ThingStatusDetail;
49 import org.openhab.core.thing.binding.BaseThingHandler;
50 import org.openhab.core.types.Command;
51 import org.openhab.core.types.State;
52 import org.slf4j.Logger;
53 import org.slf4j.LoggerFactory;
55 import com.google.gson.Gson;
58 * The {@link MeteoBlueHandler} is responsible for handling commands
59 * sent to one of the channels.
61 * @author Chris Carman - Initial contribution
63 public class MeteoBlueHandler extends BaseThingHandler {
64 private final Logger logger = LoggerFactory.getLogger(MeteoBlueHandler.class);
66 private Bridge bridge;
67 private Forecast[] forecasts;
69 private JsonData weatherData;
70 private ScheduledFuture<?> refreshJob;
71 private boolean properlyConfigured;
73 public MeteoBlueHandler(Thing thing) {
76 forecasts = new Forecast[7];
80 public void handleCommand(ChannelUID channelUID, Command command) {
81 if (properlyConfigured) {
82 logger.debug("Received command '{}' for channel '{}'", command, channelUID);
83 updateChannel(channelUID.getId());
88 public void initialize() {
89 logger.debug("Initializing the meteoblue handler...");
93 logger.warn("Unable to initialize meteoblue. No bridge was configured.");
94 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Bridge not configured.");
98 MeteoBlueConfiguration config = getConfigAs(MeteoBlueConfiguration.class);
100 if (StringUtils.isBlank(config.serviceType)) {
101 config.serviceType = MeteoBlueConfiguration.SERVICETYPE_NONCOMM;
102 logger.debug("Using default service type ({}).", config.serviceType);
106 if (StringUtils.isBlank(config.location)) {
107 flagBadConfig("The location was not configured.");
111 config.parseLocation();
113 if (config.latitude == null) {
114 flagBadConfig(String.format("Could not determine latitude from the defined location setting (%s).",
119 if (config.latitude > 90.0 || config.latitude < -90.0) {
120 flagBadConfig(String.format("Specified latitude value (%d) is not valid.", config.latitude));
124 if (config.longitude == null) {
125 flagBadConfig(String.format("Could not determine longitude from the defined location setting (%s).",
130 if (config.longitude > 180.0 || config.longitude < -180.0) {
131 flagBadConfig(String.format("Specified longitude value (%d) is not valid.", config.longitude));
135 updateStatus(ThingStatus.UNKNOWN);
136 startAutomaticRefresh(config);
137 properlyConfigured = true;
141 * Marks the configuration as invalid.
143 private void flagBadConfig(String message) {
144 properlyConfigured = false;
145 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, message);
149 * Schedule a job to periodically refresh the weather data.
151 private void startAutomaticRefresh(MeteoBlueConfiguration config) {
152 if (refreshJob != null && !refreshJob.isCancelled()) {
153 logger.trace("Refresh job already exists.");
157 Runnable runnable = () -> {
158 boolean updateSuccessful = false;
161 // Request new weather data
162 updateSuccessful = updateWeatherData();
164 if (updateSuccessful) {
165 // build forecasts from the data
166 for (int i = 0; i < 7; i++) {
167 forecasts[i] = new Forecast(i, weatherData.getMetadata(), weatherData.getUnits(),
168 weatherData.getDataDay());
171 // Update all channels from the updated weather data
172 for (Channel channel : getThing().getChannels()) {
173 updateChannel(channel.getUID().getId());
176 } catch (Exception e) {
177 logger.warn("Exception occurred during weather update: {}", e.getMessage(), e);
181 int period = config.refresh != null ? config.refresh : MeteoBlueConfiguration.DEFAULT_REFRESH;
182 refreshJob = scheduler.scheduleWithFixedDelay(runnable, 0, period, TimeUnit.MINUTES);
186 public void dispose() {
187 logger.debug("Disposing meteoblue handler.");
189 if (refreshJob != null && !refreshJob.isCancelled()) {
190 refreshJob.cancel(true);
196 * Update the channel from the last weather data retrieved.
198 * @param channelId the id of the channel to be updated
200 private void updateChannel(String channelId) {
201 Channel channel = getThing().getChannel(channelId);
202 if (channel == null || !isLinked(channelId)) {
203 logger.trace("Channel '{}' was null or not linked! Not updated.", channelId);
207 // get the set of channel parameters.
208 // the first will be the forecast day (eg. forecastToday),
209 // and the second will be the datapoint (eg. snowFraction)
210 String[] channelParts = channelId.split("#");
211 String forecastDay = channelParts[0];
212 String datapointName = channelParts[1];
213 if (channelParts.length != 2) {
214 logger.debug("Skipped invalid channelId '{}'", channelId);
218 logger.debug("Updating channel '{}'", channelId);
219 Forecast forecast = getForecast(forecastDay);
220 if (forecast == null) {
221 logger.debug("No forecast found for '{}'. Not updating.", forecastDay);
225 Object datapoint = forecast.getDatapoint(datapointName);
226 logger.debug("Value for datapoint '{}' is '{}'", datapointName, datapoint);
227 if (datapoint == null) {
228 logger.debug("Couldn't get datapoint '{}' for '{}'. Not updating.", datapointName, forecastDay);
232 // Build a State from this value
234 if (datapoint instanceof Calendar) {
235 state = new DateTimeType(
236 ZonedDateTime.ofInstant(((Calendar) datapoint).toInstant(), ZoneId.systemDefault()));
237 } else if (datapoint instanceof Integer) {
238 state = getStateForType(channel.getAcceptedItemType(), (Integer) datapoint);
239 } else if (datapoint instanceof Number) {
240 BigDecimal decimalValue = new BigDecimal(datapoint.toString()).setScale(2, RoundingMode.HALF_UP);
241 state = getStateForType(channel.getAcceptedItemType(), decimalValue);
242 } else if (datapoint instanceof String) {
243 state = new StringType(datapoint.toString());
244 } else if (datapoint instanceof BufferedImage) {
245 ImageItem item = new ImageItem("rain area");
246 state = new RawType(renderImage((BufferedImage) datapoint), "image/png");
247 item.setState(state);
249 logger.debug("Unsupported value type {}", datapoint.getClass().getSimpleName());
252 // Update the channel
254 logger.trace("Updating channel with state value {}. (object type {})", state,
255 datapoint.getClass().getSimpleName());
256 updateState(channelId, state);
260 private State getStateForType(String type, Integer value) {
261 return getStateForType(type, new BigDecimal(value));
264 private State getStateForType(String type, BigDecimal value) {
265 State state = new DecimalType(value);
267 if (type.equals("Number:Temperature")) {
268 state = new QuantityType<>(value, SIUnits.CELSIUS);
269 } else if (type.equals("Number:Length")) {
270 state = new QuantityType<>(value, MILLI(SIUnits.METRE));
271 } else if (type.equals("Number:Pressure")) {
272 state = new QuantityType<>(value, HECTO(SIUnits.PASCAL));
273 } else if (type.equals("Number:Speed")) {
274 state = new QuantityType<>(value, SmartHomeUnits.METRE_PER_SECOND);
280 // Request new weather data from the service
281 private boolean updateWeatherData() {
282 if (bridge == null) {
283 logger.debug("Unable to update weather data. Bridge missing.");
287 MeteoBlueBridgeHandler handler = (MeteoBlueBridgeHandler) bridge.getHandler();
288 if (handler == null) {
289 logger.debug("Unable to update weather data. Handler missing.");
293 String apiKey = handler.getApiKey();
295 logger.debug("Updating weather data...");
296 MeteoBlueConfiguration config = getConfigAs(MeteoBlueConfiguration.class);
297 config.parseLocation();
298 String serviceType = config.serviceType;
300 if (serviceType.equals(MeteoBlueConfiguration.SERVICETYPE_COMM)) {
301 logger.debug("Fetching weather data using Commercial API.");
303 logger.debug("Fetching weather data using NonCommercial API.");
306 // get the base url for the HTTP query
307 String url = MeteoBlueConfiguration.getURL(serviceType);
308 url = url.replace("#API_KEY#", apiKey);
309 url = url.replace("#LATITUDE#", String.valueOf(config.latitude)).replace("#LONGITUDE#",
310 String.valueOf(config.longitude));
312 // fill in any optional parameters for the HTTP query
313 StringBuilder builder = new StringBuilder();
315 if (config.altitude != null) {
316 builder.append("&asl=" + config.altitude);
318 if (StringUtils.isNotBlank(config.timeZone)) {
319 builder.append("&tz=" + config.timeZone);
321 url = url.replace("#FORMAT_PARAMS#", builder.toString());
322 logger.trace("Using URL '{}'", url);
324 // Run the HTTP request and get the JSON response
325 String httpResponse = getWeatherData(url);
326 if (httpResponse == null) {
329 JsonData jsonResult = translateJson(httpResponse, serviceType);
330 logger.trace("json object: {}", jsonResult);
332 if (jsonResult == null) {
333 logger.warn("No data was received from the weather service");
337 String errorMessage = jsonResult.getErrorMessage();
338 if (errorMessage != null) {
339 if (errorMessage.equals("MB_REQUEST::DISPATCH: Invalid api key")) {
340 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Invalid API Key");
341 } else if (errorMessage.equals("MB_REQUEST::DISPATCH: This datafeed is not authorized for your api key")) {
342 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
343 "API Key not authorized for this datafeed");
345 logger.warn("Failed to retrieve weather data due to unexpected error. Error message: {}", errorMessage);
346 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, errorMessage);
351 weatherData = jsonResult;
352 updateStatus(ThingStatus.ONLINE);
356 // Run the HTTP request and get the JSON response
357 private String getWeatherData(String url) {
359 String httpResponse = HttpUtil.executeUrl("GET", url, 30 * 1000);
360 logger.trace("http response: {}", httpResponse);
362 } catch (IOException e) {
363 logger.debug("I/O Exception occurred while retrieving weather data.", e);
364 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
365 "I/O Exception occurred while retrieving weather data.");
370 // Convert a json string response into a json data object
371 private JsonData translateJson(String stringData, String serviceType) {
372 // JsonData weatherData = null;
374 // For now, no distinction is made between commercial and non-commercial data;
375 // This may need to be changed later based on user feedback.
377 * if (serviceType.equals(MeteoBlueConfiguration.SERVICETYPE_COMM)) {
378 * weatherData = gson.fromJson(httpResponse, JsonCommercialData.class);
381 * weatherData = gson.fromJson(httpResponse, JsonNonCommercialData.class);
385 return gson.fromJson(stringData, JsonData.class);
388 private Forecast getForecast(String which) {
390 case "forecastToday":
392 case "forecastTomorrow":
409 private byte[] renderImage(BufferedImage image) {
413 ByteArrayOutputStream out = new ByteArrayOutputStream();
414 ImageIO.write(image, "png", out);
416 data = out.toByteArray();
418 } catch (IOException ioe) {
419 logger.debug("I/O exception occurred converting image data", ioe);