2 * Copyright (c) 2010-2024 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.roku.internal.handler;
15 import static org.openhab.binding.roku.internal.RokuBindingConstants.*;
17 import java.util.ArrayList;
18 import java.util.HashMap;
19 import java.util.List;
21 import java.util.concurrent.ScheduledFuture;
22 import java.util.concurrent.TimeUnit;
24 import org.eclipse.jdt.annotation.NonNullByDefault;
25 import org.eclipse.jdt.annotation.Nullable;
26 import org.eclipse.jetty.client.HttpClient;
27 import org.openhab.binding.roku.internal.RokuConfiguration;
28 import org.openhab.binding.roku.internal.RokuHttpException;
29 import org.openhab.binding.roku.internal.RokuStateDescriptionOptionProvider;
30 import org.openhab.binding.roku.internal.communication.RokuCommunicator;
31 import org.openhab.binding.roku.internal.dto.Apps.App;
32 import org.openhab.binding.roku.internal.dto.DeviceInfo;
33 import org.openhab.binding.roku.internal.dto.Player;
34 import org.openhab.binding.roku.internal.dto.TvChannel;
35 import org.openhab.binding.roku.internal.dto.TvChannels.Channel;
36 import org.openhab.core.library.types.NextPreviousType;
37 import org.openhab.core.library.types.OnOffType;
38 import org.openhab.core.library.types.PlayPauseType;
39 import org.openhab.core.library.types.QuantityType;
40 import org.openhab.core.library.types.StringType;
41 import org.openhab.core.thing.ChannelUID;
42 import org.openhab.core.thing.Thing;
43 import org.openhab.core.thing.ThingStatus;
44 import org.openhab.core.thing.ThingStatusDetail;
45 import org.openhab.core.thing.ThingTypeUID;
46 import org.openhab.core.thing.binding.BaseThingHandler;
47 import org.openhab.core.types.Command;
48 import org.openhab.core.types.RefreshType;
49 import org.openhab.core.types.StateOption;
50 import org.openhab.core.types.UnDefType;
51 import org.slf4j.Logger;
52 import org.slf4j.LoggerFactory;
55 * The {@link RokuHandler} is responsible for handling commands, which are
56 * sent to one of the channels.
58 * @author Michael Lobstein - Initial contribution
61 public class RokuHandler extends BaseThingHandler {
62 private static final int DEFAULT_REFRESH_PERIOD_SEC = 10;
64 private final Logger logger = LoggerFactory.getLogger(RokuHandler.class);
65 private final HttpClient httpClient;
66 private final RokuStateDescriptionOptionProvider stateDescriptionProvider;
68 private @Nullable ScheduledFuture<?> refreshJob;
69 private @Nullable ScheduledFuture<?> appListJob;
71 private ThingTypeUID thingTypeUID = THING_TYPE_ROKU_PLAYER;
72 private RokuCommunicator communicator;
73 private DeviceInfo deviceInfo = new DeviceInfo();
74 private int refreshInterval = DEFAULT_REFRESH_PERIOD_SEC;
75 private boolean tvActive = false;
76 private Map<String, String> appMap = new HashMap<>();
78 private Object sequenceLock = new Object();
80 public RokuHandler(Thing thing, HttpClient httpClient,
81 RokuStateDescriptionOptionProvider stateDescriptionProvider) {
83 this.httpClient = httpClient;
84 this.stateDescriptionProvider = stateDescriptionProvider;
85 this.communicator = new RokuCommunicator(httpClient, EMPTY, -1);
89 public void initialize() {
90 logger.debug("Initializing Roku handler");
91 RokuConfiguration config = getConfigAs(RokuConfiguration.class);
92 this.thingTypeUID = this.getThing().getThingTypeUID();
94 final @Nullable String host = config.hostName;
96 if (host != null && !EMPTY.equals(host)) {
97 this.communicator = new RokuCommunicator(httpClient, host, config.port);
99 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Host Name must be specified");
103 if (config.refresh >= 1) {
104 refreshInterval = config.refresh;
107 updateStatus(ThingStatus.UNKNOWN);
110 deviceInfo = communicator.getDeviceInfo();
111 thing.setProperty(PROPERTY_MODEL_NAME, deviceInfo.getModelName());
112 thing.setProperty(PROPERTY_MODEL_NUMBER, deviceInfo.getModelNumber());
113 thing.setProperty(PROPERTY_DEVICE_LOCAITON, deviceInfo.getUserDeviceLocation());
114 thing.setProperty(PROPERTY_SERIAL_NUMBER, deviceInfo.getSerialNumber());
115 thing.setProperty(PROPERTY_DEVICE_ID, deviceInfo.getDeviceId());
116 thing.setProperty(PROPERTY_SOFTWARE_VERSION, deviceInfo.getSoftwareVersion());
117 updateStatus(ThingStatus.ONLINE);
118 } catch (RokuHttpException e) {
119 logger.debug("Unable to retrieve Roku device-info. Exception: {}", e.getMessage(), e);
121 startAutomaticRefresh();
122 startAppListRefresh();
126 * Start the job to periodically get status updates from the Roku
128 private void startAutomaticRefresh() {
129 ScheduledFuture<?> refreshJob = this.refreshJob;
130 if (refreshJob == null || refreshJob.isCancelled()) {
131 this.refreshJob = scheduler.scheduleWithFixedDelay(this::refreshPlayerState, 0, refreshInterval,
137 * Get a status update from the Roku and update the channels
139 private void refreshPlayerState() {
140 synchronized (sequenceLock) {
141 String activeAppId = ROKU_HOME_ID;
143 if (thingTypeUID.equals(THING_TYPE_ROKU_TV)) {
145 deviceInfo = communicator.getDeviceInfo();
146 String powerMode = deviceInfo.getPowerMode();
147 updateState(POWER_STATE, new StringType(powerMode));
148 updateState(POWER, OnOffType.from(POWER_ON.equalsIgnoreCase(powerMode)));
149 } catch (RokuHttpException e) {
150 logger.debug("Unable to retrieve Roku device-info.", e);
154 activeAppId = communicator.getActiveApp().getApp().getId();
156 // 562859 is now reported when on the home screen, reset to -1
157 if (ROKU_HOME_ID_562859.equals(activeAppId)) {
158 activeAppId = ROKU_HOME_ID;
161 updateState(ACTIVE_APP, new StringType(activeAppId));
162 updateState(ACTIVE_APPNAME, new StringType(appMap.get(activeAppId)));
164 if (TV_APP.equals(activeAppId)) {
168 updateState(SIGNAL_MODE, UnDefType.UNDEF);
169 updateState(SIGNAL_QUALITY, UnDefType.UNDEF);
170 updateState(CHANNEL_NAME, UnDefType.UNDEF);
171 updateState(PROGRAM_TITLE, UnDefType.UNDEF);
172 updateState(PROGRAM_DESCRIPTION, UnDefType.UNDEF);
173 updateState(PROGRAM_RATING, UnDefType.UNDEF);
177 updateStatus(ThingStatus.ONLINE);
178 } catch (RokuHttpException e) {
179 logger.debug("Unable to retrieve Roku active-app info. Exception: {}", e.getMessage(), e);
180 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
183 // On the home app and when using the TV or TV inputs, do not update the play mode or time channels
184 if (!ROKU_HOME_ID.equals(activeAppId) && !activeAppId.contains(TV_INPUT)) {
186 Player playerInfo = communicator.getPlayerInfo();
187 // When nothing playing, 'close' is reported, replace with 'stop'
188 updateState(PLAY_MODE, new StringType(playerInfo.getState().replaceAll(CLOSE, STOP)));
190 PLAY.equalsIgnoreCase(playerInfo.getState()) ? PlayPauseType.PLAY : PlayPauseType.PAUSE);
192 // Remove non-numeric from string, ie: ' ms'
193 String position = playerInfo.getPosition().replaceAll(NON_DIGIT_PATTERN, EMPTY);
194 if (!EMPTY.equals(position)) {
195 updateState(TIME_ELAPSED,
196 new QuantityType<>(Integer.parseInt(position) / 1000, API_SECONDS_UNIT));
198 updateState(TIME_ELAPSED, UnDefType.UNDEF);
201 String duration = playerInfo.getDuration().replaceAll(NON_DIGIT_PATTERN, EMPTY);
202 if (!EMPTY.equals(duration)) {
203 updateState(TIME_TOTAL,
204 new QuantityType<>(Integer.parseInt(duration) / 1000, API_SECONDS_UNIT));
206 updateState(TIME_TOTAL, UnDefType.UNDEF);
208 } catch (NumberFormatException e) {
209 logger.debug("Unable to parse playerInfo integer value. Exception: {}", e.getMessage());
210 } catch (RokuHttpException e) {
211 logger.debug("Unable to retrieve Roku media-player info. Exception: {}", e.getMessage(), e);
212 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
215 updateState(PLAY_MODE, UnDefType.UNDEF);
216 updateState(TIME_ELAPSED, UnDefType.UNDEF);
217 updateState(TIME_TOTAL, UnDefType.UNDEF);
220 if (thingTypeUID.equals(THING_TYPE_ROKU_TV) && tvActive) {
222 TvChannel tvChannel = communicator.getActiveTvChannel();
223 updateState(ACTIVE_CHANNEL, new StringType(tvChannel.getChannel().getNumber()));
224 updateState(SIGNAL_MODE, new StringType(tvChannel.getChannel().getSignalMode()));
225 updateState(SIGNAL_QUALITY,
226 new QuantityType<>(tvChannel.getChannel().getSignalQuality(), API_PERCENT_UNIT));
227 updateState(CHANNEL_NAME, new StringType(tvChannel.getChannel().getName()));
228 updateState(PROGRAM_TITLE, new StringType(tvChannel.getChannel().getProgramTitle()));
229 updateState(PROGRAM_DESCRIPTION, new StringType(tvChannel.getChannel().getProgramDescription()));
230 updateState(PROGRAM_RATING, new StringType(tvChannel.getChannel().getProgramRatings()));
231 } catch (RokuHttpException e) {
232 logger.debug("Unable to retrieve Roku tv-active-channel info. Exception: {}", e.getMessage(), e);
239 * Start the job to periodically update list of apps installed on the the Roku
241 private void startAppListRefresh() {
242 ScheduledFuture<?> appListJob = this.appListJob;
243 if (appListJob == null || appListJob.isCancelled()) {
244 this.appListJob = scheduler.scheduleWithFixedDelay(this::refreshAppList, 10, 600, TimeUnit.SECONDS);
249 * Update the dropdown that lists all apps installed on the Roku
251 private void refreshAppList() {
252 synchronized (sequenceLock) {
254 List<App> appList = communicator.getAppList();
255 Map<String, String> appMap = new HashMap<>();
257 List<StateOption> appListOptions = new ArrayList<>();
258 // Roku Home will be selected in the drop-down any time an app is not running.
259 appListOptions.add(new StateOption(ROKU_HOME_ID, ROKU_HOME));
260 appMap.put(ROKU_HOME_ID, ROKU_HOME);
262 appList.forEach(app -> {
263 appListOptions.add(new StateOption(app.getId(), app.getValue()));
264 appMap.put(app.getId(), app.getValue());
267 stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), ACTIVE_APP),
270 this.appMap = appMap;
271 } catch (RokuHttpException e) {
272 logger.debug("Unable to retrieve Roku installed app-list. Exception: {}", e.getMessage(), e);
275 if (thingTypeUID.equals(THING_TYPE_ROKU_TV)) {
277 List<Channel> channelsList = communicator.getTvChannelList();
279 List<StateOption> channelListOptions = new ArrayList<>();
280 channelsList.forEach(channel -> {
281 if (!channel.isUserHidden()) {
282 channelListOptions.add(new StateOption(channel.getNumber(),
283 channel.getNumber() + " - " + channel.getName()));
287 stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), ACTIVE_CHANNEL),
290 } catch (RokuHttpException e) {
291 logger.debug("Unable to retrieve Roku tv-channels. Exception: {}", e.getMessage(), e);
298 public void dispose() {
299 ScheduledFuture<?> refreshJob = this.refreshJob;
300 if (refreshJob != null) {
301 refreshJob.cancel(true);
302 this.refreshJob = null;
305 ScheduledFuture<?> appListJob = this.appListJob;
306 if (appListJob != null) {
307 appListJob.cancel(true);
308 this.appListJob = null;
313 public void handleCommand(ChannelUID channelUID, Command command) {
314 if (command instanceof RefreshType) {
315 logger.debug("Unsupported refresh command: {}", command);
316 } else if (channelUID.getId().equals(BUTTON)) {
317 synchronized (sequenceLock) {
319 communicator.keyPress(command.toString());
320 } catch (RokuHttpException e) {
321 logger.debug("Unable to send keypress to Roku, key: {}, Exception: {}", command, e.getMessage());
322 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
325 } else if (channelUID.getId().equals(ACTIVE_APP)) {
326 synchronized (sequenceLock) {
328 String appId = command.toString();
329 // Roku Home(-1) is not a real appId, just press the home button instead
330 if (!ROKU_HOME_ID.equals(appId)) {
331 communicator.launchApp(appId);
333 communicator.keyPress(ROKU_HOME_BUTTON);
335 } catch (RokuHttpException e) {
336 logger.debug("Unable to launch app on Roku, appId: {}, Exception: {}", command, e.getMessage());
337 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
340 } else if (channelUID.getId().equals(ACTIVE_CHANNEL)) {
341 synchronized (sequenceLock) {
343 communicator.launchTvChannel(command.toString());
344 } catch (RokuHttpException e) {
345 logger.debug("Unable to change channel on Roku TV, channelNumber: {}, Exception: {}", command,
347 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
350 } else if (POWER.equals(channelUID.getId())) {
351 synchronized (sequenceLock) {
352 if (command instanceof OnOffType) {
354 if (command.equals(OnOffType.ON)) {
355 communicator.keyPress(POWER_ON);
357 communicator.keyPress("PowerOff");
359 } catch (RokuHttpException e) {
360 logger.debug("Unable to send keypress to Roku, key: {}, Exception: {}", command,
362 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
366 } else if (channelUID.getId().equals(CONTROL)) {
367 synchronized (sequenceLock) {
369 if (command instanceof PlayPauseType) {
370 communicator.keyPress(ROKU_PLAY_BUTTON);
371 } else if (command instanceof NextPreviousType) {
372 if (command == NextPreviousType.NEXT) {
373 communicator.keyPress(ROKU_NEXT_BUTTON);
374 } else if (command == NextPreviousType.PREVIOUS) {
375 communicator.keyPress(ROKU_PREV_BUTTON);
378 logger.warn("Unknown control command: {}", command);
380 } catch (RokuHttpException e) {
381 logger.debug("Unable to send control cmd to Roku, cmd: {}, Exception: {}", command, e.getMessage());
382 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
386 logger.debug("Unsupported command: {}", command);