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