]> git.basschouten.com Git - openhab-addons.git/blob
488c847aed22982e7147fc0d9fae00fbf2e42f76
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2020 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.feed.internal.handler;
14
15 import static org.openhab.binding.feed.internal.FeedBindingConstants.*;
16
17 import java.io.BufferedReader;
18 import java.io.IOException;
19 import java.io.InputStreamReader;
20 import java.math.BigDecimal;
21 import java.net.MalformedURLException;
22 import java.net.URL;
23 import java.net.URLConnection;
24 import java.time.ZoneId;
25 import java.time.ZonedDateTime;
26 import java.util.Date;
27 import java.util.List;
28 import java.util.concurrent.ScheduledFuture;
29 import java.util.concurrent.TimeUnit;
30 import java.util.zip.GZIPInputStream;
31
32 import org.eclipse.jdt.annotation.NonNullByDefault;
33 import org.eclipse.jdt.annotation.Nullable;
34 import org.openhab.core.config.core.Configuration;
35 import org.openhab.core.library.types.DateTimeType;
36 import org.openhab.core.library.types.DecimalType;
37 import org.openhab.core.library.types.StringType;
38 import org.openhab.core.thing.ChannelUID;
39 import org.openhab.core.thing.Thing;
40 import org.openhab.core.thing.ThingStatus;
41 import org.openhab.core.thing.ThingStatusDetail;
42 import org.openhab.core.thing.binding.BaseThingHandler;
43 import org.openhab.core.types.Command;
44 import org.openhab.core.types.RefreshType;
45 import org.openhab.core.types.State;
46 import org.openhab.core.types.UnDefType;
47 import org.slf4j.Logger;
48 import org.slf4j.LoggerFactory;
49
50 import com.rometools.rome.feed.synd.SyndEntry;
51 import com.rometools.rome.feed.synd.SyndFeed;
52 import com.rometools.rome.io.FeedException;
53 import com.rometools.rome.io.SyndFeedInput;
54
55 /**
56  * The {@link FeedHandler } is responsible for handling commands, which are
57  * sent to one of the channels and for the regular updates of the feed data.
58  *
59  * @author Svilen Valkanov - Initial contribution
60  */
61 @NonNullByDefault
62 public class FeedHandler extends BaseThingHandler {
63
64     private final Logger logger = LoggerFactory.getLogger(FeedHandler.class);
65
66     private @Nullable URL url;
67     private long refreshTime;
68     private @Nullable ScheduledFuture<?> refreshTask;
69     private @Nullable SyndFeed currentFeedState;
70     private long lastRefreshTime;
71
72     public FeedHandler(Thing thing) {
73         super(thing);
74     }
75
76     @Override
77     public void initialize() {
78         if (checkConfiguration()) {
79             updateStatus(ThingStatus.UNKNOWN);
80             startAutomaticRefresh();
81         }
82     }
83
84     /**
85      * This method checks if the provided configuration is valid.
86      * When invalid parameter is found, default value is assigned.
87      */
88     private boolean checkConfiguration() {
89         logger.debug("Start reading Feed Thing configuration.");
90         Configuration configuration = getConfig();
91
92         // It is not necessary to check if the URL is valid, this will be done in fetchFeedData() method
93         String urlString = (String) configuration.get(URL);
94         try {
95             url = new URL(urlString);
96         } catch (MalformedURLException e) {
97             logger.warn("Url '{}' is not valid: ", urlString, e);
98             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, e.getMessage());
99             return false;
100         }
101
102         BigDecimal localRefreshTime = null;
103         try {
104             localRefreshTime = (BigDecimal) configuration.get(REFRESH_TIME);
105             if (localRefreshTime.intValue() <= 0) {
106                 throw new IllegalArgumentException("Refresh time must be positive number!");
107             }
108             refreshTime = localRefreshTime.longValue();
109         } catch (Exception e) {
110             logger.warn("Refresh time [{}] is not valid. Falling back to default value: {}. {}", localRefreshTime,
111                     DEFAULT_REFRESH_TIME, e.getMessage());
112             refreshTime = DEFAULT_REFRESH_TIME;
113         }
114         return true;
115     }
116
117     private void startAutomaticRefresh() {
118         refreshTask = scheduler.scheduleWithFixedDelay(this::refreshFeedState, 0, refreshTime, TimeUnit.MINUTES);
119         logger.debug("Start automatic refresh at {} minutes!", refreshTime);
120     }
121
122     private void refreshFeedState() {
123         SyndFeed feed = fetchFeedData();
124         boolean feedUpdated = updateFeedIfChanged(feed);
125         if (feedUpdated) {
126             getThing().getChannels().forEach(channel -> publishChannelIfLinked(channel.getUID()));
127         }
128     }
129
130     private void publishChannelIfLinked(ChannelUID channelUID) {
131         String channelID = channelUID.getId();
132
133         SyndFeed feedState = currentFeedState;
134         if (feedState == null) {
135             // This will happen if the binding could not download data from the server
136             logger.trace("Cannot update channel with ID {}; no data has been downloaded from the server!", channelID);
137             return;
138         }
139
140         if (!isLinked(channelUID)) {
141             logger.trace("Cannot update channel with ID {}; not linked!", channelID);
142             return;
143         }
144
145         State state = null;
146         SyndEntry latestEntry = getLatestEntry(feedState);
147
148         switch (channelID) {
149             case CHANNEL_LATEST_TITLE:
150                 if (latestEntry == null || latestEntry.getTitle() == null) {
151                     state = UnDefType.UNDEF;
152                 } else {
153                     String title = latestEntry.getTitle();
154                     state = new StringType(getValueSafely(title));
155                 }
156                 break;
157             case CHANNEL_LATEST_DESCRIPTION:
158                 if (latestEntry == null || latestEntry.getDescription() == null) {
159                     state = UnDefType.UNDEF;
160                 } else {
161                     String description = latestEntry.getDescription().getValue();
162                     state = new StringType(getValueSafely(description));
163                 }
164                 break;
165             case CHANNEL_LATEST_LINK:
166                 if (latestEntry == null || latestEntry.getLink() == null) {
167                     state = UnDefType.UNDEF;
168                 } else {
169                     state = new StringType(getValueSafely(latestEntry.getLink()));
170                 }
171                 break;
172             case CHANNEL_LATEST_PUBLISHED_DATE:
173             case CHANNEL_LAST_UPDATE:
174                 if (latestEntry == null || latestEntry.getPublishedDate() == null) {
175                     logger.debug("Cannot update date channel. No date found in feed.");
176                     return;
177                 } else {
178                     Date date = latestEntry.getPublishedDate();
179                     ZonedDateTime zdt = ZonedDateTime.ofInstant(date.toInstant(), ZoneId.systemDefault());
180                     state = new DateTimeType(zdt);
181                 }
182                 break;
183             case CHANNEL_AUTHOR:
184                 String author = feedState.getAuthor();
185                 state = new StringType(getValueSafely(author));
186                 break;
187             case CHANNEL_DESCRIPTION:
188                 String channelDescription = feedState.getDescription();
189                 state = new StringType(getValueSafely(channelDescription));
190                 break;
191             case CHANNEL_TITLE:
192                 String channelTitle = feedState.getTitle();
193                 state = new StringType(getValueSafely(channelTitle));
194                 break;
195             case CHANNEL_NUMBER_OF_ENTRIES:
196                 int numberOfEntries = feedState.getEntries().size();
197                 state = new DecimalType(numberOfEntries);
198                 break;
199             default:
200                 logger.debug("Unrecognized channel: {}", channelID);
201         }
202
203         if (state != null) {
204             updateState(channelID, state);
205         } else {
206             logger.debug("Cannot update channel with ID {}; state not defined!", channelID);
207         }
208     }
209
210     /**
211      * This method updates the {@link #currentFeedState}, only if there are changes on the server, since the last check.
212      * It compares the content on the server with the local
213      * stored {@link #currentFeedState} in the {@link FeedHandler}.
214      *
215      * @return <code>true</code> if new content is available on the server since the last update or <code>false</code>
216      *         otherwise
217      */
218     private synchronized boolean updateFeedIfChanged(@Nullable SyndFeed newFeedState) {
219         // SyndFeed class has implementation of equals ()
220         if (newFeedState != null && !newFeedState.equals(currentFeedState)) {
221             currentFeedState = newFeedState;
222             logger.debug("New content available!");
223             return true;
224         }
225         logger.debug("Feed content has not changed!");
226         return false;
227     }
228
229     /**
230      * This method tries to make connection with the server and fetch data from the feed.
231      * The status of the feed thing is set to {@link ThingStatus#ONLINE}, if the fetching was successful.
232      * Otherwise the status will be set to {@link ThingStatus#OFFLINE} with
233      * {@link ThingStatusDetail#CONFIGURATION_ERROR} or
234      * {@link ThingStatusDetail#COMMUNICATION_ERROR} and adequate message.
235      *
236      * @return {@link SyndFeed} instance with the feed data, if the connection attempt was successful and
237      *         <code>null</code> otherwise
238      */
239     private @Nullable SyndFeed fetchFeedData() {
240         URL localUrl = url;
241         if (localUrl == null) {
242             logger.trace("Url '{}' is not valid: ", localUrl);
243             return null;
244         }
245
246         try {
247             URLConnection connection = localUrl.openConnection();
248             connection.setRequestProperty("Accept-Encoding", "gzip");
249
250             BufferedReader in = null;
251             if ("gzip".equals(connection.getContentEncoding())) {
252                 in = new BufferedReader(new InputStreamReader(new GZIPInputStream(connection.getInputStream())));
253             } else {
254                 in = new BufferedReader(new InputStreamReader(connection.getInputStream()));
255             }
256
257             SyndFeedInput input = new SyndFeedInput();
258             SyndFeed feed = input.build(in);
259             in.close();
260
261             if (this.thing.getStatus() != ThingStatus.ONLINE) {
262                 updateStatus(ThingStatus.ONLINE);
263             }
264
265             return feed;
266         } catch (IOException e) {
267             logger.warn("Error accessing feed: {}", localUrl, e);
268             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
269             return null;
270         } catch (IllegalArgumentException e) {
271             logger.warn("Feed URL is null: {} ", localUrl, e);
272             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, e.getMessage());
273             return null;
274         } catch (FeedException e) {
275             logger.warn("Feed content is not valid: {} ", localUrl, e);
276             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, e.getMessage());
277             return null;
278         }
279     }
280
281     /**
282      * Returns the most recent entry or null, if no entries are found.
283      */
284     private @Nullable SyndEntry getLatestEntry(SyndFeed feed) {
285         List<SyndEntry> allEntries = feed.getEntries();
286         if (!allEntries.isEmpty()) {
287             /*
288              * The entries are stored in the SyndFeed object in the following order -
289              * the newest entry has index 0. The order is determined from the time the entry was posted, not the
290              * published time of the entry.
291              */
292             return allEntries.get(0);
293         } else {
294             logger.debug("No entries found");
295         }
296         return null;
297     }
298
299     @Override
300     public void handleCommand(ChannelUID channelUID, Command command) {
301         if (command instanceof RefreshType) {
302             // safeguard for multiple REFRESH commands for different channels in a row
303             if (isMinimumRefreshTimeExceeded()) {
304                 SyndFeed feed = fetchFeedData();
305                 updateFeedIfChanged(feed);
306             }
307             publishChannelIfLinked(channelUID);
308         } else {
309             logger.debug("Command {} is not supported for channel: {}. Supported command: REFRESH", command,
310                     channelUID.getId());
311         }
312     }
313
314     @Override
315     public void dispose() {
316         if (refreshTask != null) {
317             refreshTask.cancel(true);
318         }
319         lastRefreshTime = 0;
320     }
321
322     private boolean isMinimumRefreshTimeExceeded() {
323         long currentTime = System.currentTimeMillis();
324         long timeSinceLastRefresh = currentTime - lastRefreshTime;
325         if (timeSinceLastRefresh < MINIMUM_REFRESH_TIME) {
326             return false;
327         }
328         lastRefreshTime = currentTime;
329         return true;
330     }
331
332     public String getValueSafely(@Nullable String value) {
333         return value == null ? "" : value;
334     }
335 }