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.feed.internal.handler;
15 import static org.openhab.binding.feed.internal.FeedBindingConstants.*;
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;
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;
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;
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;
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.
59 * @author Svilen Valkanov - Initial contribution
60 * @author Juergen Pabel - Added enclosure channel
63 public class FeedHandler extends BaseThingHandler {
65 private final Logger logger = LoggerFactory.getLogger(FeedHandler.class);
67 private @Nullable URL url;
68 private long refreshTime;
69 private @Nullable ScheduledFuture<?> refreshTask;
70 private @Nullable SyndFeed currentFeedState;
71 private long lastRefreshTime;
73 public FeedHandler(Thing thing) {
78 public void initialize() {
79 if (checkConfiguration()) {
80 updateStatus(ThingStatus.UNKNOWN);
81 startAutomaticRefresh();
86 * This method checks if the provided configuration is valid.
87 * When invalid parameter is found, default value is assigned.
89 private boolean checkConfiguration() {
90 logger.debug("Start reading Feed Thing configuration.");
91 Configuration configuration = getConfig();
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);
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());
103 BigDecimal localRefreshTime = null;
105 localRefreshTime = (BigDecimal) configuration.get(REFRESH_TIME);
106 if (localRefreshTime.intValue() <= 0) {
107 throw new IllegalArgumentException("Refresh time must be positive number!");
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;
118 private void startAutomaticRefresh() {
119 refreshTask = scheduler.scheduleWithFixedDelay(this::refreshFeedState, 0, refreshTime, TimeUnit.MINUTES);
120 logger.debug("Start automatic refresh at {} minutes!", refreshTime);
123 private void refreshFeedState() {
124 SyndFeed feed = fetchFeedData();
125 boolean feedUpdated = updateFeedIfChanged(feed);
127 getThing().getChannels().forEach(channel -> publishChannelIfLinked(channel.getUID()));
131 private void publishChannelIfLinked(ChannelUID channelUID) {
132 String channelID = channelUID.getId();
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);
141 if (!isLinked(channelUID)) {
142 logger.trace("Cannot update channel with ID {}; not linked!", channelID);
147 SyndEntry latestEntry = getLatestEntry(feedState);
150 case CHANNEL_LATEST_TITLE:
151 if (latestEntry == null || latestEntry.getTitle() == null) {
152 state = UnDefType.UNDEF;
154 String title = latestEntry.getTitle();
155 state = new StringType(getValueSafely(title));
158 case CHANNEL_LATEST_DESCRIPTION:
159 if (latestEntry == null || latestEntry.getDescription() == null) {
160 state = UnDefType.UNDEF;
162 String description = latestEntry.getDescription().getValue();
163 state = new StringType(getValueSafely(description));
166 case CHANNEL_LATEST_LINK:
167 if (latestEntry == null || latestEntry.getLink() == null) {
168 state = UnDefType.UNDEF;
170 state = new StringType(getValueSafely(latestEntry.getLink()));
173 case CHANNEL_LATEST_ENCLOSURE:
174 if (latestEntry == null || latestEntry.getEnclosures().isEmpty()) {
175 state = UnDefType.UNDEF;
177 state = new StringType(getValueSafely(latestEntry.getEnclosures().get(0).getUrl()));
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.");
186 Date date = latestEntry.getPublishedDate();
187 ZonedDateTime zdt = ZonedDateTime.ofInstant(date.toInstant(), ZoneId.systemDefault());
188 state = new DateTimeType(zdt);
192 String author = feedState.getAuthor();
193 state = new StringType(getValueSafely(author));
195 case CHANNEL_DESCRIPTION:
196 String channelDescription = feedState.getDescription();
197 state = new StringType(getValueSafely(channelDescription));
200 String channelTitle = feedState.getTitle();
201 state = new StringType(getValueSafely(channelTitle));
203 case CHANNEL_NUMBER_OF_ENTRIES:
204 int numberOfEntries = feedState.getEntries().size();
205 state = new DecimalType(numberOfEntries);
208 logger.debug("Unrecognized channel: {}", channelID);
212 updateState(channelID, state);
214 logger.debug("Cannot update channel with ID {}; state not defined!", channelID);
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}.
223 * @return <code>true</code> if new content is available on the server since the last update or <code>false</code>
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!");
233 logger.debug("Feed content has not changed!");
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.
244 * @return {@link SyndFeed} instance with the feed data, if the connection attempt was successful and
245 * <code>null</code> otherwise
247 private @Nullable SyndFeed fetchFeedData() {
249 if (localUrl == null) {
250 logger.trace("Url '{}' is not valid: ", localUrl);
255 URLConnection connection = localUrl.openConnection();
256 connection.setRequestProperty("Accept-Encoding", "gzip");
258 BufferedReader in = null;
259 if ("gzip".equals(connection.getContentEncoding())) {
260 in = new BufferedReader(new InputStreamReader(new GZIPInputStream(connection.getInputStream())));
262 in = new BufferedReader(new InputStreamReader(connection.getInputStream()));
265 SyndFeedInput input = new SyndFeedInput();
266 SyndFeed feed = input.build(in);
269 if (this.thing.getStatus() != ThingStatus.ONLINE) {
270 updateStatus(ThingStatus.ONLINE);
274 } catch (IOException e) {
275 logger.warn("Error accessing feed: {}", localUrl, e);
276 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
278 } catch (IllegalArgumentException e) {
279 logger.warn("Feed URL is null: {} ", localUrl, e);
280 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, e.getMessage());
282 } catch (FeedException e) {
283 logger.warn("Feed content is not valid: {} ", localUrl, e);
284 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, e.getMessage());
290 * Returns the most recent entry or null, if no entries are found.
292 private @Nullable SyndEntry getLatestEntry(SyndFeed feed) {
293 List<SyndEntry> allEntries = feed.getEntries();
294 if (!allEntries.isEmpty()) {
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.
300 return allEntries.get(0);
302 logger.debug("No entries found");
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);
315 publishChannelIfLinked(channelUID);
317 logger.debug("Command {} is not supported for channel: {}. Supported command: REFRESH", command,
323 public void dispose() {
324 if (refreshTask != null) {
325 refreshTask.cancel(true);
330 private boolean isMinimumRefreshTimeExceeded() {
331 long currentTime = System.currentTimeMillis();
332 long timeSinceLastRefresh = currentTime - lastRefreshTime;
333 if (timeSinceLastRefresh < MINIMUM_REFRESH_TIME) {
336 lastRefreshTime = currentTime;
340 public String getValueSafely(@Nullable String value) {
341 return value == null ? "" : value;