]> git.basschouten.com Git - openhab-addons.git/blob
b4a78aeb4418acb8550c97d87db61380812a3288
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2022 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.darksky.internal.handler;
14
15 import static org.openhab.binding.darksky.internal.DarkSkyBindingConstants.*;
16 import static org.openhab.core.library.unit.MetricPrefix.*;
17 import static org.openhab.core.library.unit.SIUnits.*;
18 import static org.openhab.core.library.unit.Units.*;
19
20 import java.time.Instant;
21 import java.time.ZoneId;
22 import java.time.ZonedDateTime;
23 import java.time.format.DateTimeFormatter;
24 import java.time.temporal.ChronoUnit;
25 import java.util.ArrayList;
26 import java.util.Collections;
27 import java.util.List;
28 import java.util.Map;
29 import java.util.Set;
30 import java.util.concurrent.ConcurrentHashMap;
31 import java.util.concurrent.ScheduledFuture;
32 import java.util.concurrent.TimeUnit;
33 import java.util.regex.Matcher;
34 import java.util.regex.Pattern;
35
36 import javax.measure.Unit;
37
38 import org.eclipse.jdt.annotation.NonNullByDefault;
39 import org.eclipse.jdt.annotation.Nullable;
40 import org.openhab.binding.darksky.internal.config.DarkSkyChannelConfiguration;
41 import org.openhab.binding.darksky.internal.config.DarkSkyWeatherAndForecastConfiguration;
42 import org.openhab.binding.darksky.internal.connection.DarkSkyCommunicationException;
43 import org.openhab.binding.darksky.internal.connection.DarkSkyConfigurationException;
44 import org.openhab.binding.darksky.internal.connection.DarkSkyConnection;
45 import org.openhab.binding.darksky.internal.model.DarkSkyCurrentlyData;
46 import org.openhab.binding.darksky.internal.model.DarkSkyDailyData.DailyData;
47 import org.openhab.binding.darksky.internal.model.DarkSkyHourlyData.HourlyData;
48 import org.openhab.binding.darksky.internal.model.DarkSkyJsonWeatherData;
49 import org.openhab.binding.darksky.internal.model.DarkSkyJsonWeatherData.AlertsData;
50 import org.openhab.core.library.types.DateTimeType;
51 import org.openhab.core.library.types.DecimalType;
52 import org.openhab.core.library.types.PointType;
53 import org.openhab.core.library.types.QuantityType;
54 import org.openhab.core.library.types.RawType;
55 import org.openhab.core.library.types.StringType;
56 import org.openhab.core.thing.Channel;
57 import org.openhab.core.thing.ChannelGroupUID;
58 import org.openhab.core.thing.ChannelUID;
59 import org.openhab.core.thing.Thing;
60 import org.openhab.core.thing.ThingStatus;
61 import org.openhab.core.thing.ThingStatusDetail;
62 import org.openhab.core.thing.ThingStatusInfo;
63 import org.openhab.core.thing.ThingTypeUID;
64 import org.openhab.core.thing.binding.BaseThingHandler;
65 import org.openhab.core.thing.binding.ThingHandlerCallback;
66 import org.openhab.core.thing.binding.builder.ChannelBuilder;
67 import org.openhab.core.thing.binding.builder.ThingBuilder;
68 import org.openhab.core.thing.type.ChannelGroupTypeUID;
69 import org.openhab.core.thing.type.ChannelKind;
70 import org.openhab.core.types.Command;
71 import org.openhab.core.types.RefreshType;
72 import org.openhab.core.types.State;
73 import org.openhab.core.types.UnDefType;
74 import org.slf4j.Logger;
75 import org.slf4j.LoggerFactory;
76
77 import com.google.gson.JsonSyntaxException;
78
79 /**
80  * The {@link DarkSkyWeatherAndForecastHandler} is responsible for handling commands, which are sent to one of the
81  * channels.
82  *
83  * @author Christoph Weitkamp - Initial contribution
84  */
85 @NonNullByDefault
86 public class DarkSkyWeatherAndForecastHandler extends BaseThingHandler {
87
88     private final Logger logger = LoggerFactory.getLogger(DarkSkyWeatherAndForecastHandler.class);
89
90     public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Collections
91             .singleton(THING_TYPE_WEATHER_AND_FORECAST);
92
93     private static final String PRECIP_TYPE_SNOW = "snow";
94     private static final String PRECIP_TYPE_RAIN = "rain";
95
96     private static final String CHANNEL_GROUP_HOURLY_FORECAST_PREFIX = "forecastHours";
97     private static final String CHANNEL_GROUP_DAILY_FORECAST_PREFIX = "forecastDay";
98     private static final String CHANNEL_GROUP_ALERTS_PREFIX = "alerts";
99     private static final Pattern CHANNEL_GROUP_HOURLY_FORECAST_PREFIX_PATTERN = Pattern
100             .compile(CHANNEL_GROUP_HOURLY_FORECAST_PREFIX + "([0-9]*)");
101     private static final Pattern CHANNEL_GROUP_DAILY_FORECAST_PREFIX_PATTERN = Pattern
102             .compile(CHANNEL_GROUP_DAILY_FORECAST_PREFIX + "([0-9]*)");
103     private static final Pattern CHANNEL_GROUP_ALERTS_PREFIX_PATTERN = Pattern
104             .compile(CHANNEL_GROUP_ALERTS_PREFIX + "([0-9]*)");
105
106     // keeps track of all jobs
107     private static final Map<String, Job> JOBS = new ConcurrentHashMap<>();
108
109     // keeps track of the parsed location
110     protected @Nullable PointType location;
111     // keeps track of the parsed counts
112     private int forecastHours = 24;
113     private int forecastDays = 8;
114     private int numberOfAlerts = 0;
115
116     private @Nullable DarkSkyChannelConfiguration sunriseTriggerChannelConfig;
117     private @Nullable DarkSkyChannelConfiguration sunsetTriggerChannelConfig;
118     private @Nullable DarkSkyJsonWeatherData weatherData;
119
120     public DarkSkyWeatherAndForecastHandler(Thing thing) {
121         super(thing);
122     }
123
124     @Override
125     public void initialize() {
126         logger.debug("Initialize DarkSkyWeatherAndForecastHandler handler '{}'.", getThing().getUID());
127         DarkSkyWeatherAndForecastConfiguration config = getConfigAs(DarkSkyWeatherAndForecastConfiguration.class);
128
129         boolean configValid = true;
130         if (config.location == null || config.location.trim().isEmpty()) {
131             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
132                     "@text/offline.conf-error-missing-location");
133             configValid = false;
134         }
135
136         try {
137             location = new PointType(config.location);
138         } catch (IllegalArgumentException e) {
139             logger.warn("Error parsing 'location' parameter: {}", e.getMessage());
140             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
141                     "@text/offline.conf-error-parsing-location");
142             location = null;
143             configValid = false;
144         }
145
146         int newForecastHours = config.forecastHours;
147         if (newForecastHours < 0 || newForecastHours > 48) {
148             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
149                     "@text/offline.conf-error-not-supported-number-of-hours");
150             configValid = false;
151         }
152         int newForecastDays = config.forecastDays;
153         if (newForecastDays < 0 || newForecastDays > 8) {
154             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
155                     "@text/offline.conf-error-not-supported-number-of-days");
156             configValid = false;
157         }
158         int newNumberOfAlerts = config.numberOfAlerts;
159         if (newNumberOfAlerts < 0) {
160             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
161                     "@text/offline.conf-error-not-supported-number-of-alerts");
162             configValid = false;
163         }
164
165         if (configValid) {
166             logger.debug("Rebuilding thing '{}'.", getThing().getUID());
167             List<Channel> toBeAddedChannels = new ArrayList<>();
168             List<Channel> toBeRemovedChannels = new ArrayList<>();
169             if (forecastHours != newForecastHours) {
170                 logger.debug("Rebuilding hourly forecast channel groups.");
171                 if (forecastHours > newForecastHours) {
172                     for (int i = newForecastHours + 1; i <= forecastHours; ++i) {
173                         toBeRemovedChannels.addAll(removeChannelsOfGroup(
174                                 CHANNEL_GROUP_HOURLY_FORECAST_PREFIX + ((i < 10) ? "0" : "") + Integer.toString(i)));
175                     }
176                 } else {
177                     for (int i = forecastHours + 1; i <= newForecastHours; ++i) {
178                         toBeAddedChannels.addAll(createChannelsForGroup(
179                                 CHANNEL_GROUP_HOURLY_FORECAST_PREFIX + ((i < 10) ? "0" : "") + Integer.toString(i),
180                                 CHANNEL_GROUP_TYPE_HOURLY_FORECAST));
181                     }
182                 }
183                 forecastHours = newForecastHours;
184             }
185             if (forecastDays != newForecastDays) {
186                 logger.debug("Rebuilding daily forecast channel groups.");
187                 if (forecastDays > newForecastDays) {
188                     if (newForecastDays < 1) {
189                         toBeRemovedChannels.addAll(removeChannelsOfGroup(CHANNEL_GROUP_FORECAST_TODAY));
190                     }
191                     if (newForecastDays < 2) {
192                         toBeRemovedChannels.addAll(removeChannelsOfGroup(CHANNEL_GROUP_FORECAST_TOMORROW));
193                     }
194                     for (int i = newForecastDays; i < forecastDays; ++i) {
195                         toBeRemovedChannels.addAll(
196                                 removeChannelsOfGroup(CHANNEL_GROUP_DAILY_FORECAST_PREFIX + Integer.toString(i)));
197                     }
198                 } else {
199                     if (forecastDays == 0 && newForecastDays > 0) {
200                         toBeAddedChannels.addAll(createChannelsForGroup(CHANNEL_GROUP_FORECAST_TODAY,
201                                 CHANNEL_GROUP_TYPE_DAILY_FORECAST));
202                     }
203                     if (forecastDays <= 1 && newForecastDays > 1) {
204                         toBeAddedChannels.addAll(createChannelsForGroup(CHANNEL_GROUP_FORECAST_TOMORROW,
205                                 CHANNEL_GROUP_TYPE_DAILY_FORECAST));
206                     }
207                     for (int i = (forecastDays < 2) ? 2 : forecastDays; i < newForecastDays; ++i) {
208                         toBeAddedChannels.addAll(
209                                 createChannelsForGroup(CHANNEL_GROUP_DAILY_FORECAST_PREFIX + Integer.toString(i),
210                                         CHANNEL_GROUP_TYPE_DAILY_FORECAST));
211                     }
212                 }
213                 forecastDays = newForecastDays;
214             }
215             if (numberOfAlerts != newNumberOfAlerts) {
216                 logger.debug("Rebuilding alerts channel groups.");
217                 if (numberOfAlerts > newNumberOfAlerts) {
218                     for (int i = newNumberOfAlerts + 1; i <= numberOfAlerts; ++i) {
219                         toBeRemovedChannels
220                                 .addAll(removeChannelsOfGroup(CHANNEL_GROUP_ALERTS_PREFIX + Integer.toString(i)));
221                     }
222                 } else {
223                     for (int i = numberOfAlerts + 1; i <= newNumberOfAlerts; ++i) {
224                         toBeAddedChannels.addAll(createChannelsForGroup(
225                                 CHANNEL_GROUP_ALERTS_PREFIX + Integer.toString(i), CHANNEL_GROUP_TYPE_ALERTS));
226                     }
227                 }
228                 numberOfAlerts = newNumberOfAlerts;
229             }
230             ThingBuilder builder = editThing().withoutChannels(toBeRemovedChannels);
231             for (Channel channel : toBeAddedChannels) {
232                 builder.withChannel(channel);
233             }
234             updateThing(builder.build());
235
236             Channel sunriseTriggerChannel = getThing().getChannel(TRIGGER_SUNRISE);
237             sunriseTriggerChannelConfig = (sunriseTriggerChannel == null) ? null
238                     : sunriseTriggerChannel.getConfiguration().as(DarkSkyChannelConfiguration.class);
239             Channel sunsetTriggerChannel = getThing().getChannel(TRIGGER_SUNSET);
240             sunsetTriggerChannelConfig = (sunsetTriggerChannel == null) ? null
241                     : sunsetTriggerChannel.getConfiguration().as(DarkSkyChannelConfiguration.class);
242
243             updateStatus(ThingStatus.UNKNOWN);
244         }
245     }
246
247     @Override
248     public void dispose() {
249         cancelAllJobs();
250     }
251
252     @Override
253     public void handleCommand(ChannelUID channelUID, Command command) {
254         if (command instanceof RefreshType) {
255             updateChannel(channelUID);
256         } else {
257             logger.debug("The Dark Sky binding is a read-only binding and cannot handle command '{}'.", command);
258         }
259     }
260
261     @Override
262     public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
263         if (ThingStatus.ONLINE.equals(bridgeStatusInfo.getStatus())
264                 && ThingStatusDetail.BRIDGE_OFFLINE.equals(getThing().getStatusInfo().getStatusDetail())) {
265             updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE);
266         } else if (ThingStatus.OFFLINE.equals(bridgeStatusInfo.getStatus())
267                 && !ThingStatus.OFFLINE.equals(getThing().getStatus())) {
268             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
269         }
270     }
271
272     /**
273      * Creates all {@link Channel}s for the given {@link ChannelGroupTypeUID}.
274      *
275      * @param channelGroupId the channel group id
276      * @param channelGroupTypeUID the {@link ChannelGroupTypeUID}
277      * @return a list of all {@link Channel}s for the channel group
278      */
279     private List<Channel> createChannelsForGroup(String channelGroupId, ChannelGroupTypeUID channelGroupTypeUID) {
280         logger.debug("Building channel group '{}' for thing '{}'.", channelGroupId, getThing().getUID());
281         List<Channel> channels = new ArrayList<>();
282         ThingHandlerCallback callback = getCallback();
283         if (callback != null) {
284             for (ChannelBuilder channelBuilder : callback.createChannelBuilders(
285                     new ChannelGroupUID(getThing().getUID(), channelGroupId), channelGroupTypeUID)) {
286                 Channel newChannel = channelBuilder.build(),
287                         existingChannel = getThing().getChannel(newChannel.getUID().getId());
288                 if (existingChannel != null) {
289                     logger.trace("Thing '{}' already has an existing channel '{}'. Omit adding new channel '{}'.",
290                             getThing().getUID(), existingChannel.getUID(), newChannel.getUID());
291                     continue;
292                 }
293                 channels.add(newChannel);
294             }
295         }
296         return channels;
297     }
298
299     /**
300      * Removes all {@link Channel}s of the given channel group.
301      *
302      * @param channelGroupId the channel group id
303      * @return a list of all {@link Channel}s in the given channel group
304      */
305     private List<Channel> removeChannelsOfGroup(String channelGroupId) {
306         logger.debug("Removing channel group '{}' from thing '{}'.", channelGroupId, getThing().getUID());
307         return getThing().getChannelsOfGroup(channelGroupId);
308     }
309
310     /**
311      * Updates Dark Sky data for this location.
312      *
313      * @param connection {@link DarkSkyConnection} instance
314      */
315     public void updateData(DarkSkyConnection connection) {
316         try {
317             if (requestData(connection)) {
318                 updateChannels();
319                 updateStatus(ThingStatus.ONLINE);
320             }
321         } catch (DarkSkyCommunicationException e) {
322             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getLocalizedMessage());
323         } catch (DarkSkyConfigurationException e) {
324             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getLocalizedMessage());
325         }
326     }
327
328     /**
329      * Requests the data from Dark Sky API.
330      *
331      * @param connection {@link DarkSkyConnection} instance
332      * @return true, if the request for the Dark Sky data was successful
333      * @throws DarkSkyCommunicationException
334      * @throws DarkSkyConfigurationException
335      */
336     private boolean requestData(DarkSkyConnection connection)
337             throws DarkSkyCommunicationException, DarkSkyConfigurationException {
338         logger.debug("Update weather and forecast data of thing '{}'.", getThing().getUID());
339         try {
340             weatherData = connection.getWeatherData(location);
341             return true;
342         } catch (JsonSyntaxException e) {
343             logger.debug("JsonSyntaxException occurred during execution: {}", e.getLocalizedMessage(), e);
344             return false;
345         }
346     }
347
348     /**
349      * Updates all channels of this handler from the latest Dark Sky data retrieved.
350      */
351     private void updateChannels() {
352         for (Channel channel : getThing().getChannels()) {
353             ChannelUID channelUID = channel.getUID();
354             if (ChannelKind.STATE.equals(channel.getKind()) && channelUID.isInGroup() && channelUID.getGroupId() != null
355                     && isLinked(channelUID)) {
356                 updateChannel(channelUID);
357             }
358         }
359     }
360
361     /**
362      * Updates the channel with the given UID from the latest Dark Sky data retrieved.
363      *
364      * @param channelUID UID of the channel
365      */
366     private void updateChannel(ChannelUID channelUID) {
367         String channelGroupId = channelUID.getGroupId();
368         switch (channelGroupId) {
369             case CHANNEL_GROUP_CURRENT_WEATHER:
370                 updateCurrentChannel(channelUID);
371                 break;
372             case CHANNEL_GROUP_FORECAST_TODAY:
373                 updateDailyForecastChannel(channelUID, 0);
374                 break;
375             case CHANNEL_GROUP_FORECAST_TOMORROW:
376                 updateDailyForecastChannel(channelUID, 1);
377                 break;
378             default:
379                 int i;
380                 Matcher hourlyForecastMatcher = CHANNEL_GROUP_HOURLY_FORECAST_PREFIX_PATTERN.matcher(channelGroupId);
381                 if (hourlyForecastMatcher.find() && (i = Integer.parseInt(hourlyForecastMatcher.group(1))) >= 1
382                         && i <= 48) {
383                     updateHourlyForecastChannel(channelUID, i);
384                     break;
385                 }
386                 Matcher dailyForecastMatcher = CHANNEL_GROUP_DAILY_FORECAST_PREFIX_PATTERN.matcher(channelGroupId);
387                 if (dailyForecastMatcher.find() && (i = Integer.parseInt(dailyForecastMatcher.group(1))) > 1
388                         && i <= 8) {
389                     updateDailyForecastChannel(channelUID, i);
390                     break;
391                 }
392                 Matcher alertsMatcher = CHANNEL_GROUP_ALERTS_PREFIX_PATTERN.matcher(channelGroupId);
393                 if (alertsMatcher.find() && (i = Integer.parseInt(alertsMatcher.group(1))) >= 1) {
394                     updateAlertsChannel(channelUID, i);
395                     break;
396                 }
397                 logger.warn("Unknown channel group '{}'. Cannot update channel '{}'.", channelGroupId, channelUID);
398                 break;
399         }
400     }
401
402     /**
403      * Update the channel from the last Dark Sky data retrieved.
404      *
405      * @param channelUID the id identifying the channel to be updated
406      */
407     private void updateCurrentChannel(ChannelUID channelUID) {
408         String channelId = channelUID.getIdWithoutGroup();
409         String channelGroupId = channelUID.getGroupId();
410         if (weatherData != null && weatherData.getCurrently() != null) {
411             DarkSkyCurrentlyData currentData = weatherData.getCurrently();
412             State state = UnDefType.UNDEF;
413             switch (channelId) {
414                 case CHANNEL_TIME_STAMP:
415                     state = getDateTimeTypeState(currentData.getTime());
416                     break;
417                 case CHANNEL_CONDITION:
418                     state = getStringTypeState(currentData.getSummary());
419                     break;
420                 case CHANNEL_CONDITION_ICON:
421                     state = getRawTypeState(DarkSkyConnection.getWeatherIcon(currentData.getIcon()));
422                     break;
423                 case CHANNEL_CONDITION_ICON_ID:
424                     state = getStringTypeState(currentData.getIcon());
425                     break;
426                 case CHANNEL_TEMPERATURE:
427                     state = getQuantityTypeState(currentData.getTemperature(), CELSIUS);
428                     break;
429                 case CHANNEL_APPARENT_TEMPERATURE:
430                     state = getQuantityTypeState(currentData.getApparentTemperature(), CELSIUS);
431                     break;
432                 case CHANNEL_PRESSURE:
433                     state = getQuantityTypeState(currentData.getPressure(), HECTO(PASCAL));
434                     break;
435                 case CHANNEL_HUMIDITY:
436                     state = getQuantityTypeState(currentData.getHumidity() * 100, PERCENT);
437                     break;
438                 case CHANNEL_WIND_SPEED:
439                     state = getQuantityTypeState(currentData.getWindSpeed(), METRE_PER_SECOND);
440                     break;
441                 case CHANNEL_WIND_DIRECTION:
442                     state = getQuantityTypeState(currentData.getWindBearing(), DEGREE_ANGLE);
443                     break;
444                 case CHANNEL_GUST_SPEED:
445                     state = getQuantityTypeState(currentData.getWindGust(), METRE_PER_SECOND);
446                     break;
447                 case CHANNEL_CLOUDINESS:
448                     state = getQuantityTypeState(currentData.getCloudCover() * 100, PERCENT);
449                     break;
450                 case CHANNEL_VISIBILITY:
451                     state = getQuantityTypeState(currentData.getVisibility(), KILO(METRE));
452                     break;
453                 case CHANNEL_RAIN:
454                     state = getQuantityTypeState(
455                             PRECIP_TYPE_RAIN.equals(currentData.getPrecipType()) ? currentData.getPrecipIntensity() : 0,
456                             MILLIMETRE_PER_HOUR);
457                     break;
458                 case CHANNEL_SNOW:
459                     state = getQuantityTypeState(
460                             PRECIP_TYPE_SNOW.equals(currentData.getPrecipType()) ? currentData.getPrecipIntensity() : 0,
461                             MILLIMETRE_PER_HOUR);
462                     break;
463                 case CHANNEL_PRECIPITATION_INTENSITY:
464                     state = getQuantityTypeState(currentData.getPrecipIntensity(), MILLIMETRE_PER_HOUR);
465                     break;
466                 case CHANNEL_PRECIPITATION_PROBABILITY:
467                     state = getQuantityTypeState(currentData.getPrecipProbability() * 100, PERCENT);
468                     break;
469                 case CHANNEL_PRECIPITATION_TYPE:
470                     state = getStringTypeState(currentData.getPrecipType());
471                     break;
472                 case CHANNEL_UVINDEX:
473                     state = getDecimalTypeState(currentData.getUvIndex());
474                     break;
475                 case CHANNEL_OZONE:
476                     state = getQuantityTypeState(currentData.getOzone(), DOBSON_UNIT);
477                     break;
478                 case CHANNEL_SUNRISE:
479                 case CHANNEL_SUNSET:
480                     updateDailyForecastChannel(channelUID, 0);
481                     return;
482             }
483             logger.debug("Update channel '{}' of group '{}' with new state '{}'.", channelId, channelGroupId, state);
484             updateState(channelUID, state);
485         } else {
486             logger.debug("No weather data available to update channel '{}' of group '{}'.", channelId, channelGroupId);
487         }
488     }
489
490     /**
491      * Update the channel from the last Dark Sky data retrieved.
492      *
493      * @param channelUID the id identifying the channel to be updated
494      * @param count
495      */
496     private void updateHourlyForecastChannel(ChannelUID channelUID, int count) {
497         String channelId = channelUID.getIdWithoutGroup();
498         String channelGroupId = channelUID.getGroupId();
499         if (weatherData != null && weatherData.getHourly() != null
500                 && weatherData.getHourly().getData().size() > count) {
501             HourlyData forecastData = weatherData.getHourly().getData().get(count);
502             State state = UnDefType.UNDEF;
503             switch (channelId) {
504                 case CHANNEL_TIME_STAMP:
505                     state = getDateTimeTypeState(forecastData.getTime());
506                     break;
507                 case CHANNEL_CONDITION:
508                     state = getStringTypeState(forecastData.getSummary());
509                     break;
510                 case CHANNEL_CONDITION_ICON:
511                     state = getRawTypeState(DarkSkyConnection.getWeatherIcon(forecastData.getIcon()));
512                     break;
513                 case CHANNEL_CONDITION_ICON_ID:
514                     state = getStringTypeState(forecastData.getIcon());
515                     break;
516                 case CHANNEL_TEMPERATURE:
517                     state = getQuantityTypeState(forecastData.getTemperature(), CELSIUS);
518                     break;
519                 case CHANNEL_APPARENT_TEMPERATURE:
520                     state = getQuantityTypeState(forecastData.getApparentTemperature(), CELSIUS);
521                     break;
522                 case CHANNEL_PRESSURE:
523                     state = getQuantityTypeState(forecastData.getPressure(), HECTO(PASCAL));
524                     break;
525                 case CHANNEL_HUMIDITY:
526                     state = getQuantityTypeState(forecastData.getHumidity() * 100, PERCENT);
527                     break;
528                 case CHANNEL_WIND_SPEED:
529                     state = getQuantityTypeState(forecastData.getWindSpeed(), METRE_PER_SECOND);
530                     break;
531                 case CHANNEL_WIND_DIRECTION:
532                     state = getQuantityTypeState(forecastData.getWindBearing(), DEGREE_ANGLE);
533                     break;
534                 case CHANNEL_GUST_SPEED:
535                     state = getQuantityTypeState(forecastData.getWindGust(), METRE_PER_SECOND);
536                     break;
537                 case CHANNEL_CLOUDINESS:
538                     state = getQuantityTypeState(forecastData.getCloudCover() * 100, PERCENT);
539                     break;
540                 case CHANNEL_VISIBILITY:
541                     state = getQuantityTypeState(forecastData.getVisibility(), KILO(METRE));
542                     break;
543                 case CHANNEL_RAIN:
544                     state = getQuantityTypeState(
545                             PRECIP_TYPE_RAIN.equals(forecastData.getPrecipType()) ? forecastData.getPrecipIntensity()
546                                     : 0,
547                             MILLIMETRE_PER_HOUR);
548                     break;
549                 case CHANNEL_SNOW:
550                     state = getQuantityTypeState(
551                             PRECIP_TYPE_SNOW.equals(forecastData.getPrecipType()) ? forecastData.getPrecipIntensity()
552                                     : 0,
553                             MILLIMETRE_PER_HOUR);
554                     break;
555                 case CHANNEL_PRECIPITATION_INTENSITY:
556                     state = getQuantityTypeState(forecastData.getPrecipIntensity(), MILLIMETRE_PER_HOUR);
557                     break;
558                 case CHANNEL_PRECIPITATION_PROBABILITY:
559                     state = getQuantityTypeState(forecastData.getPrecipProbability() * 100, PERCENT);
560                     break;
561                 case CHANNEL_PRECIPITATION_TYPE:
562                     state = getStringTypeState(forecastData.getPrecipType());
563                     break;
564                 case CHANNEL_UVINDEX:
565                     state = getDecimalTypeState(forecastData.getUvIndex());
566                     break;
567                 case CHANNEL_OZONE:
568                     state = getQuantityTypeState(forecastData.getOzone(), DOBSON_UNIT);
569                     break;
570             }
571             logger.debug("Update channel '{}' of group '{}' with new state '{}'.", channelId, channelGroupId, state);
572             updateState(channelUID, state);
573         } else {
574             logger.debug("No weather data available to update channel '{}' of group '{}'.", channelId, channelGroupId);
575         }
576     }
577
578     /**
579      * Update the channel from the last Dark Sky data retrieved.
580      *
581      * @param channelUID the id identifying the channel to be updated
582      * @param count
583      */
584     private void updateDailyForecastChannel(ChannelUID channelUID, int count) {
585         String channelId = channelUID.getIdWithoutGroup();
586         String channelGroupId = channelUID.getGroupId();
587         if (weatherData != null && weatherData.getDaily() != null && weatherData.getDaily().getData().size() > count) {
588             DailyData forecastData = weatherData.getDaily().getData().get(count);
589             State state = UnDefType.UNDEF;
590             switch (channelId) {
591                 case CHANNEL_TIME_STAMP:
592                     state = getDateTimeTypeState(forecastData.getTime());
593                     break;
594                 case CHANNEL_CONDITION:
595                     state = getStringTypeState(forecastData.getSummary());
596                     break;
597                 case CHANNEL_CONDITION_ICON:
598                     state = getRawTypeState(DarkSkyConnection.getWeatherIcon(forecastData.getIcon()));
599                     break;
600                 case CHANNEL_CONDITION_ICON_ID:
601                     state = getStringTypeState(forecastData.getIcon());
602                     break;
603                 case CHANNEL_MIN_TEMPERATURE:
604                     state = getQuantityTypeState(forecastData.getTemperatureMin(), CELSIUS);
605                     break;
606                 case CHANNEL_MAX_TEMPERATURE:
607                     state = getQuantityTypeState(forecastData.getTemperatureMax(), CELSIUS);
608                     break;
609                 case CHANNEL_MIN_APPARENT_TEMPERATURE:
610                     state = getQuantityTypeState(forecastData.getApparentTemperatureMin(), CELSIUS);
611                     break;
612                 case CHANNEL_MAX_APPARENT_TEMPERATURE:
613                     state = getQuantityTypeState(forecastData.getApparentTemperatureMax(), CELSIUS);
614                     break;
615                 case CHANNEL_PRESSURE:
616                     state = getQuantityTypeState(forecastData.getPressure(), HECTO(PASCAL));
617                     break;
618                 case CHANNEL_HUMIDITY:
619                     state = getQuantityTypeState(forecastData.getHumidity() * 100, PERCENT);
620                     break;
621                 case CHANNEL_WIND_SPEED:
622                     state = getQuantityTypeState(forecastData.getWindSpeed(), METRE_PER_SECOND);
623                     break;
624                 case CHANNEL_WIND_DIRECTION:
625                     state = getQuantityTypeState(forecastData.getWindBearing(), DEGREE_ANGLE);
626                     break;
627                 case CHANNEL_GUST_SPEED:
628                     state = getQuantityTypeState(forecastData.getWindGust(), METRE_PER_SECOND);
629                     break;
630                 case CHANNEL_CLOUDINESS:
631                     state = getQuantityTypeState(forecastData.getCloudCover() * 100, PERCENT);
632                     break;
633                 case CHANNEL_VISIBILITY:
634                     state = getQuantityTypeState(forecastData.getVisibility(), KILO(METRE));
635                     break;
636                 case CHANNEL_RAIN:
637                     state = getQuantityTypeState(
638                             PRECIP_TYPE_RAIN.equals(forecastData.getPrecipType()) ? forecastData.getPrecipIntensity()
639                                     : 0,
640                             MILLIMETRE_PER_HOUR);
641                     break;
642                 case CHANNEL_SNOW:
643                     state = getQuantityTypeState(
644                             PRECIP_TYPE_SNOW.equals(forecastData.getPrecipType()) ? forecastData.getPrecipIntensity()
645                                     : 0,
646                             MILLIMETRE_PER_HOUR);
647                     break;
648                 case CHANNEL_PRECIPITATION_INTENSITY:
649                     state = getQuantityTypeState(forecastData.getPrecipIntensity(), MILLIMETRE_PER_HOUR);
650                     break;
651                 case CHANNEL_PRECIPITATION_PROBABILITY:
652                     state = getQuantityTypeState(forecastData.getPrecipProbability() * 100, PERCENT);
653                     break;
654                 case CHANNEL_PRECIPITATION_TYPE:
655                     state = getStringTypeState(forecastData.getPrecipType());
656                     break;
657                 case CHANNEL_UVINDEX:
658                     state = getDecimalTypeState(forecastData.getUvIndex());
659                     break;
660                 case CHANNEL_OZONE:
661                     state = getQuantityTypeState(forecastData.getOzone(), DOBSON_UNIT);
662                     break;
663                 case CHANNEL_SUNRISE:
664                     state = getDateTimeTypeState(forecastData.getSunriseTime());
665                     if (count == 0 && state instanceof DateTimeType) {
666                         scheduleJob(TRIGGER_SUNRISE, applyChannelConfig(((DateTimeType) state).getZonedDateTime(),
667                                 sunriseTriggerChannelConfig));
668                     }
669                     break;
670                 case CHANNEL_SUNSET:
671                     state = getDateTimeTypeState(forecastData.getSunsetTime());
672                     if (count == 0 && state instanceof DateTimeType) {
673                         scheduleJob(TRIGGER_SUNSET, applyChannelConfig(((DateTimeType) state).getZonedDateTime(),
674                                 sunsetTriggerChannelConfig));
675                     }
676                     break;
677             }
678             logger.debug("Update channel '{}' of group '{}' with new state '{}'.", channelId, channelGroupId, state);
679             updateState(channelUID, state);
680         } else {
681             logger.debug("No weather data available to update channel '{}' of group '{}'.", channelId, channelGroupId);
682         }
683     }
684
685     /**
686      * Update the channel from the last Dark Sky data retrieved.
687      *
688      * @param channelUID the id identifying the channel to be updated
689      * @param count
690      */
691     private void updateAlertsChannel(ChannelUID channelUID, int count) {
692         String channelId = channelUID.getIdWithoutGroup();
693         String channelGroupId = channelUID.getGroupId();
694         List<AlertsData> alerts = weatherData != null ? weatherData.getAlerts() : null;
695         State state = UnDefType.UNDEF;
696         if (alerts != null && alerts.size() > count) {
697             AlertsData alertsData = alerts.get(count - 1);
698             switch (channelId) {
699                 case CHANNEL_ALERT_TITLE:
700                     state = getStringTypeState(alertsData.title);
701                     break;
702                 case CHANNEL_ALERT_DESCRIPTION:
703                     state = getStringTypeState(alertsData.description);
704                     break;
705                 case CHANNEL_ALERT_SEVERITY:
706                     state = getStringTypeState(alertsData.severity);
707                     break;
708                 case CHANNEL_ALERT_ISSUED:
709                     state = getDateTimeTypeState(alertsData.time);
710                     break;
711                 case CHANNEL_ALERT_EXPIRES:
712                     state = getDateTimeTypeState(alertsData.expires);
713                     break;
714                 case CHANNEL_ALERT_URI:
715                     state = getStringTypeState(alertsData.uri);
716                     break;
717             }
718             logger.debug("Update channel '{}' of group '{}' with new state '{}'.", channelId, channelGroupId, state);
719         } else {
720             logger.debug("No data available to update channel '{}' of group '{}'.", channelId, channelGroupId);
721         }
722         updateState(channelUID, state);
723     }
724
725     private State getDateTimeTypeState(int value) {
726         return new DateTimeType(ZonedDateTime.ofInstant(Instant.ofEpochSecond(value), ZoneId.systemDefault()));
727     }
728
729     private State getDecimalTypeState(int value) {
730         return new DecimalType(value);
731     }
732
733     private State getRawTypeState(@Nullable RawType image) {
734         return (image == null) ? UnDefType.UNDEF : image;
735     }
736
737     private State getStringTypeState(@Nullable String value) {
738         return (value == null) ? UnDefType.UNDEF : new StringType(value);
739     }
740
741     private State getQuantityTypeState(double value, Unit<?> unit) {
742         return new QuantityType<>(value, unit);
743     }
744
745     /**
746      * Applies the given configuration to the given timestamp.
747      *
748      * @param dateTime timestamp represented as {@link ZonedDateTime}
749      * @param config {@link DarkSkyChannelConfiguration} instance
750      * @return the modified timestamp
751      */
752     private ZonedDateTime applyChannelConfig(ZonedDateTime dateTime, @Nullable DarkSkyChannelConfiguration config) {
753         ZonedDateTime modifiedDateTime = dateTime;
754         if (config != null) {
755             if (config.getOffset() != 0) {
756                 if (logger.isTraceEnabled()) {
757                     logger.trace("Apply offset of {} min to timestamp '{}'.", config.getOffset(),
758                             modifiedDateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
759                 }
760                 modifiedDateTime = modifiedDateTime.plusMinutes(config.getOffset());
761             }
762             long earliestInMinutes = config.getEarliestInMinutes();
763             if (earliestInMinutes > 0) {
764                 ZonedDateTime earliestDateTime = modifiedDateTime.truncatedTo(ChronoUnit.DAYS)
765                         .plusMinutes(earliestInMinutes);
766                 if (modifiedDateTime.isBefore(earliestDateTime)) {
767                     if (logger.isTraceEnabled()) {
768                         logger.trace("Use earliest timestamp '{}' instead of '{}'.",
769                                 earliestDateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME),
770                                 modifiedDateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
771                     }
772                     return earliestDateTime;
773                 }
774             }
775             long latestInMinutes = config.getLatestInMinutes();
776             if (latestInMinutes > 0) {
777                 ZonedDateTime latestDateTime = modifiedDateTime.truncatedTo(ChronoUnit.DAYS)
778                         .plusMinutes(latestInMinutes);
779                 if (modifiedDateTime.isAfter(latestDateTime)) {
780                     if (logger.isTraceEnabled()) {
781                         logger.trace("Use latest timestamp '{}' instead of '{}'.",
782                                 latestDateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME),
783                                 modifiedDateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
784                     }
785                     return latestDateTime;
786                 }
787             }
788         }
789         return modifiedDateTime;
790     }
791
792     /**
793      * Schedules or reschedules a job for the channel with the given id if the given timestamp is in the future.
794      *
795      * @param channelId id of the channel
796      * @param dateTime timestamp of the job represented as {@link ZonedDateTime}
797      */
798     @SuppressWarnings("null")
799     private synchronized void scheduleJob(String channelId, ZonedDateTime dateTime) {
800         long delay = dateTime.toEpochSecond() - ZonedDateTime.now().toEpochSecond();
801         if (delay > 0) {
802             Job job = JOBS.get(channelId);
803             if (job == null || job.getFuture().isCancelled()) {
804                 if (logger.isDebugEnabled()) {
805                     logger.debug("Schedule job for '{}' in {} s (at '{}').", channelId, delay,
806                             dateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
807                 }
808                 JOBS.put(channelId, new Job(channelId, delay));
809             } else {
810                 if (delay != job.getDelay()) {
811                     if (logger.isDebugEnabled()) {
812                         logger.debug("Reschedule job for '{}' in {} s (at '{}').", channelId, delay,
813                                 dateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
814                     }
815                     job.getFuture().cancel(true);
816                     JOBS.put(channelId, new Job(channelId, delay));
817                 }
818             }
819         }
820     }
821
822     /**
823      * Cancels all jobs.
824      */
825     private void cancelAllJobs() {
826         logger.debug("Cancel all jobs.");
827         JOBS.keySet().forEach(this::cancelJob);
828     }
829
830     /**
831      * Cancels the job for the channel with the given id.
832      *
833      * @param channelId id of the channel
834      */
835     @SuppressWarnings("null")
836     private synchronized void cancelJob(String channelId) {
837         Job job = JOBS.remove(channelId);
838         if (job != null && !job.getFuture().isCancelled()) {
839             logger.debug("Cancel job for '{}'.", channelId);
840             job.getFuture().cancel(true);
841         }
842     }
843
844     /**
845      * Executes the job for the channel with the given id.
846      *
847      * @param channelId id of the channel
848      */
849     private void executeJob(String channelId) {
850         logger.debug("Trigger channel '{}' with event '{}'.", channelId, EVENT_START);
851         triggerChannel(channelId, EVENT_START);
852     }
853
854     private final class Job {
855         private final long delay;
856         private final ScheduledFuture<?> future;
857
858         public Job(String event, long delay) {
859             this.delay = delay;
860             this.future = scheduler.schedule(() -> {
861                 executeJob(event);
862             }, delay, TimeUnit.SECONDS);
863         }
864
865         public long getDelay() {
866             return delay;
867         }
868
869         public ScheduledFuture<?> getFuture() {
870             return future;
871         }
872     }
873 }