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.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.openhab.binding.meteoblue.internal.Forecast;
31 import org.openhab.binding.meteoblue.internal.MeteoBlueConfiguration;
32 import org.openhab.binding.meteoblue.internal.json.JsonData;
33 import org.openhab.core.io.net.http.HttpUtil;
34 import org.openhab.core.library.items.ImageItem;
35 import org.openhab.core.library.types.DateTimeType;
36 import org.openhab.core.library.types.DecimalType;
37 import org.openhab.core.library.types.QuantityType;
38 import org.openhab.core.library.types.RawType;
39 import org.openhab.core.library.types.StringType;
40 import org.openhab.core.library.unit.SIUnits;
41 import org.openhab.core.library.unit.Units;
42 import org.openhab.core.thing.Bridge;
43 import org.openhab.core.thing.Channel;
44 import org.openhab.core.thing.ChannelUID;
45 import org.openhab.core.thing.Thing;
46 import org.openhab.core.thing.ThingStatus;
47 import org.openhab.core.thing.ThingStatusDetail;
48 import org.openhab.core.thing.binding.BaseThingHandler;
49 import org.openhab.core.types.Command;
50 import org.openhab.core.types.State;
51 import org.slf4j.Logger;
52 import org.slf4j.LoggerFactory;
54 import com.google.gson.Gson;
57 * The {@link MeteoBlueHandler} is responsible for handling commands
58 * sent to one of the channels.
60 * @author Chris Carman - Initial contribution
62 public class MeteoBlueHandler extends BaseThingHandler {
63 private final Logger logger = LoggerFactory.getLogger(MeteoBlueHandler.class);
65 private Bridge bridge;
66 private Forecast[] forecasts;
68 private JsonData weatherData;
69 private ScheduledFuture<?> refreshJob;
70 private boolean properlyConfigured;
72 public MeteoBlueHandler(Thing thing) {
75 forecasts = new Forecast[7];
79 public void handleCommand(ChannelUID channelUID, Command command) {
80 if (properlyConfigured) {
81 logger.debug("Received command '{}' for channel '{}'", command, channelUID);
82 updateChannel(channelUID.getId());
87 public void initialize() {
88 logger.debug("Initializing the meteoblue handler...");
92 logger.warn("Unable to initialize meteoblue. No bridge was configured.");
93 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Bridge not configured.");
97 MeteoBlueConfiguration config = getConfigAs(MeteoBlueConfiguration.class);
99 if (config.serviceType == null || config.serviceType.isBlank()) {
100 config.serviceType = MeteoBlueConfiguration.SERVICETYPE_NONCOMM;
101 logger.debug("Using default service type ({}).", config.serviceType);
105 if (config.location == null || config.location.isBlank()) {
106 flagBadConfig("The location was not configured.");
110 config.parseLocation();
112 if (config.latitude == null) {
113 flagBadConfig(String.format("Could not determine latitude from the defined location setting (%s).",
118 if (config.latitude > 90.0 || config.latitude < -90.0) {
119 flagBadConfig(String.format("Specified latitude value (%d) is not valid.", config.latitude));
123 if (config.longitude == null) {
124 flagBadConfig(String.format("Could not determine longitude from the defined location setting (%s).",
129 if (config.longitude > 180.0 || config.longitude < -180.0) {
130 flagBadConfig(String.format("Specified longitude value (%d) is not valid.", config.longitude));
134 updateStatus(ThingStatus.UNKNOWN);
135 startAutomaticRefresh(config);
136 properlyConfigured = true;
140 * Marks the configuration as invalid.
142 private void flagBadConfig(String message) {
143 properlyConfigured = false;
144 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, message);
148 * Schedule a job to periodically refresh the weather data.
150 private void startAutomaticRefresh(MeteoBlueConfiguration config) {
151 if (refreshJob != null && !refreshJob.isCancelled()) {
152 logger.trace("Refresh job already exists.");
156 Runnable runnable = () -> {
157 boolean updateSuccessful = false;
160 // Request new weather data
161 updateSuccessful = updateWeatherData();
163 if (updateSuccessful) {
164 // build forecasts from the data
165 for (int i = 0; i < 7; i++) {
166 forecasts[i] = new Forecast(i, weatherData.getMetadata(), weatherData.getUnits(),
167 weatherData.getDataDay());
170 // Update all channels from the updated weather data
171 for (Channel channel : getThing().getChannels()) {
172 updateChannel(channel.getUID().getId());
175 } catch (Exception e) {
176 logger.warn("Exception occurred during weather update: {}", e.getMessage(), e);
180 int period = config.refresh != null ? config.refresh : MeteoBlueConfiguration.DEFAULT_REFRESH;
181 refreshJob = scheduler.scheduleWithFixedDelay(runnable, 0, period, TimeUnit.MINUTES);
185 public void dispose() {
186 logger.debug("Disposing meteoblue handler.");
188 if (refreshJob != null && !refreshJob.isCancelled()) {
189 refreshJob.cancel(true);
195 * Update the channel from the last weather data retrieved.
197 * @param channelId the id of the channel to be updated
199 private void updateChannel(String channelId) {
200 Channel channel = getThing().getChannel(channelId);
201 if (channel == null || !isLinked(channelId)) {
202 logger.trace("Channel '{}' was null or not linked! Not updated.", channelId);
206 // get the set of channel parameters.
207 // the first will be the forecast day (eg. forecastToday),
208 // and the second will be the datapoint (eg. snowFraction)
209 String[] channelParts = channelId.split("#");
210 String forecastDay = channelParts[0];
211 String datapointName = channelParts[1];
212 if (channelParts.length != 2) {
213 logger.debug("Skipped invalid channelId '{}'", channelId);
217 logger.debug("Updating channel '{}'", channelId);
218 Forecast forecast = getForecast(forecastDay);
219 if (forecast == null) {
220 logger.debug("No forecast found for '{}'. Not updating.", forecastDay);
224 Object datapoint = forecast.getDatapoint(datapointName);
225 logger.debug("Value for datapoint '{}' is '{}'", datapointName, datapoint);
226 if (datapoint == null) {
227 logger.debug("Couldn't get datapoint '{}' for '{}'. Not updating.", datapointName, forecastDay);
231 // Build a State from this value
233 if (datapoint instanceof Calendar calendar) {
234 state = new DateTimeType(ZonedDateTime.ofInstant(calendar.toInstant(), ZoneId.systemDefault()));
235 } else if (datapoint instanceof Integer) {
236 state = getStateForType(channel.getAcceptedItemType(), (Integer) datapoint);
237 } else if (datapoint instanceof Number) {
238 BigDecimal decimalValue = new BigDecimal(datapoint.toString()).setScale(2, RoundingMode.HALF_UP);
239 state = getStateForType(channel.getAcceptedItemType(), decimalValue);
240 } else if (datapoint instanceof String) {
241 state = new StringType(datapoint.toString());
242 } else if (datapoint instanceof BufferedImage image) {
243 ImageItem item = new ImageItem("rain area");
244 state = new RawType(renderImage(image), "image/png");
245 item.setState(state);
247 logger.debug("Unsupported value type {}", datapoint.getClass().getSimpleName());
250 // Update the channel
252 logger.trace("Updating channel with state value {}. (object type {})", state,
253 datapoint.getClass().getSimpleName());
254 updateState(channelId, state);
258 private State getStateForType(String type, Integer value) {
259 return getStateForType(type, new BigDecimal(value));
262 private State getStateForType(String type, BigDecimal value) {
263 State state = new DecimalType(value);
265 if ("Number:Temperature".equals(type)) {
266 state = new QuantityType<>(value, SIUnits.CELSIUS);
267 } else if ("Number:Length".equals(type)) {
268 state = new QuantityType<>(value, MILLI(SIUnits.METRE));
269 } else if ("Number:Pressure".equals(type)) {
270 state = new QuantityType<>(value, HECTO(SIUnits.PASCAL));
271 } else if ("Number:Speed".equals(type)) {
272 state = new QuantityType<>(value, Units.METRE_PER_SECOND);
278 // Request new weather data from the service
279 private boolean updateWeatherData() {
280 if (bridge == null) {
281 logger.debug("Unable to update weather data. Bridge missing.");
285 MeteoBlueBridgeHandler handler = (MeteoBlueBridgeHandler) bridge.getHandler();
286 if (handler == null) {
287 logger.debug("Unable to update weather data. Handler missing.");
291 String apiKey = handler.getApiKey();
293 logger.debug("Updating weather data...");
294 MeteoBlueConfiguration config = getConfigAs(MeteoBlueConfiguration.class);
295 config.parseLocation();
296 String serviceType = config.serviceType;
298 if (serviceType.equals(MeteoBlueConfiguration.SERVICETYPE_COMM)) {
299 logger.debug("Fetching weather data using Commercial API.");
301 logger.debug("Fetching weather data using NonCommercial API.");
304 // get the base url for the HTTP query
305 String url = MeteoBlueConfiguration.getURL(serviceType);
306 url = url.replace("#API_KEY#", apiKey);
307 url = url.replace("#LATITUDE#", String.valueOf(config.latitude)).replace("#LONGITUDE#",
308 String.valueOf(config.longitude));
310 // fill in any optional parameters for the HTTP query
311 StringBuilder builder = new StringBuilder();
313 if (config.altitude != null) {
314 builder.append("&asl=" + config.altitude);
316 if (config.timeZone != null && !config.timeZone.isBlank()) {
317 builder.append("&tz=" + config.timeZone);
319 url = url.replace("#FORMAT_PARAMS#", builder.toString());
320 logger.trace("Using URL '{}'", url);
322 // Run the HTTP request and get the JSON response
323 String httpResponse = getWeatherData(url);
324 if (httpResponse == null) {
327 JsonData jsonResult = translateJson(httpResponse, serviceType);
328 logger.trace("json object: {}", jsonResult);
330 if (jsonResult == null) {
331 logger.warn("No data was received from the weather service");
335 String errorMessage = jsonResult.getErrorMessage();
336 if (errorMessage != null) {
337 if ("MB_REQUEST::DISPATCH: Invalid api key".equals(errorMessage)) {
338 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Invalid API Key");
339 } else if ("MB_REQUEST::DISPATCH: This datafeed is not authorized for your api key".equals(errorMessage)) {
340 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
341 "API Key not authorized for this datafeed");
343 logger.warn("Failed to retrieve weather data due to unexpected error. Error message: {}", errorMessage);
344 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, errorMessage);
349 weatherData = jsonResult;
350 updateStatus(ThingStatus.ONLINE);
354 // Run the HTTP request and get the JSON response
355 private String getWeatherData(String url) {
357 String httpResponse = HttpUtil.executeUrl("GET", url, 30 * 1000);
358 logger.trace("http response: {}", httpResponse);
360 } catch (IOException e) {
361 logger.debug("I/O Exception occurred while retrieving weather data.", e);
362 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
363 "I/O Exception occurred while retrieving weather data.");
368 // Convert a json string response into a json data object
369 private JsonData translateJson(String stringData, String serviceType) {
370 // JsonData weatherData = null;
372 // For now, no distinction is made between commercial and non-commercial data;
373 // This may need to be changed later based on user feedback.
375 * if (serviceType.equals(MeteoBlueConfiguration.SERVICETYPE_COMM)) {
376 * weatherData = gson.fromJson(httpResponse, JsonCommercialData.class);
379 * weatherData = gson.fromJson(httpResponse, JsonNonCommercialData.class);
383 return gson.fromJson(stringData, JsonData.class);
386 private Forecast getForecast(String which) {
388 case "forecastToday":
390 case "forecastTomorrow":
407 private byte[] renderImage(BufferedImage image) {
411 ByteArrayOutputStream out = new ByteArrayOutputStream();
412 ImageIO.write(image, "png", out);
414 data = out.toByteArray();
416 } catch (IOException ioe) {
417 logger.debug("I/O exception occurred converting image data", ioe);