diff --git a/CODEOWNERS b/CODEOWNERS
index e877a114f9f09..409e1927fcbc8 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -218,6 +218,7 @@
/bundles/org.openhab.binding.rfxcom/ @martinvw @paulianttila
/bundles/org.openhab.binding.rme/ @kgoderis
/bundles/org.openhab.binding.robonect/ @reyem
+/bundles/org.openhab.binding.roku/ @mlobstein
/bundles/org.openhab.binding.rotel/ @lolodomo
/bundles/org.openhab.binding.russound/ @tmrobert8
/bundles/org.openhab.binding.sagercaster/ @clinique
diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml
index 63d59e0574639..a9c38a1e54caf 100644
--- a/bom/openhab-addons/pom.xml
+++ b/bom/openhab-addons/pom.xml
@@ -1076,6 +1076,11 @@
org.openhab.binding.robonect
${project.version}
+
+ org.openhab.addons.bundles
+ org.openhab.binding.roku
+ ${project.version}
+
org.openhab.addons.bundles
org.openhab.binding.rotel
diff --git a/bundles/org.openhab.binding.roku/NOTICE b/bundles/org.openhab.binding.roku/NOTICE
new file mode 100644
index 0000000000000..38d625e349232
--- /dev/null
+++ b/bundles/org.openhab.binding.roku/NOTICE
@@ -0,0 +1,13 @@
+This content is produced and maintained by the openHAB project.
+
+* Project home: https://www.openhab.org
+
+== Declared Project Licenses
+
+This program and the accompanying materials are made available under the terms
+of the Eclipse Public License 2.0 which is available at
+https://www.eclipse.org/legal/epl-2.0/.
+
+== Source Code
+
+https://github.com/openhab/openhab-addons
diff --git a/bundles/org.openhab.binding.roku/README.md b/bundles/org.openhab.binding.roku/README.md
new file mode 100644
index 0000000000000..b3d92ccff2e5f
--- /dev/null
+++ b/bundles/org.openhab.binding.roku/README.md
@@ -0,0 +1,111 @@
+# Roku Binding
+
+This binding connects Roku streaming media players and Roku TVs to openHAB.
+The Roku device must support the Roku ECP protocol REST API.
+
+## Supported Things
+
+There are two supported thing types, which represent either a standalone Roku device or a Roku TV.
+A supported Roku streaming media player or streaming stick uses the `roku_player` id and a supported Roku TV uses the `roku_tv` id.
+The binding functionality is the same for both types, but the Roku TV type adds additional button commands to the button channel dropdown.
+Multiple Things can be added if more than one Roku is to be controlled.
+
+## Discovery
+
+Auto-discovery is supported if the Roku can be located on the local network using SSDP.
+Otherwise the thing must be manually added.
+
+## Binding Configuration
+
+The binding has no configuration options, all configuration is done at Thing level.
+
+## Thing Configuration
+
+The thing has a few configuration parameters:
+
+| Parameter | Description |
+|-----------|------------------------------------------------------------------------------------------------------------|
+| hostName | The host name or IP address of the Roku device. Mandatory. |
+| port | The port on the Roku that listens for http connections. Default 8060 |
+| refresh | Overrides the refresh interval for player status updates. Optional, the default and minimum is 10 seconds. |
+
+## Channels
+
+The following channels are available:
+
+| Channel ID | Item Type | Description |
+|-----------------|-------------|---------------------------------------------------------------------------------------------------------------------------------------------------------|
+| activeApp | String | A dropdown containing a list of all apps installed on the Roku. The app currently running is automatically selected. The list updates every 10 minutes. |
+| button | String | Sends a remote control command the Roku. See list of available commands below. |
+| playMode | String | The current playback mode ie: stop, play, pause (ReadOnly). |
+| timeElapsed | Number:Time | The total number of seconds of playback time elapsed for the current playing title (ReadOnly). |
+| timeTotal | Number:Time | The total length of the current playing title in seconds (ReadOnly). This data is not provided by all streaming apps. |
+
+Some Notes:
+
+* The values for `activeApp`, `playMode`, `timeElapsed` & `timeTotal` refresh automatically per the configured `refresh` interval (10 seconds minimum).
+
+**List of available button commands for Roku streaming devices:**
+Home
+Rev
+Fwd
+Play
+Select
+Left
+Right
+Up
+Down
+Back
+InstantReplay
+Info
+Backspace
+Search
+Enter
+FindRemote
+
+**List of additional button commands for Roku TVs:**
+ChannelUp
+ChannelDown
+VolumeUp
+VolumeDown
+VolumeMute
+InputTuner
+InputHDMI1
+InputHDMI2
+InputHDMI3
+InputHDMI4
+InputAV1
+PowerOff
+
+## Full Example
+
+roku.things:
+
+```java
+roku:roku_player:myplayer1 "My Roku" [ hostName="192.168.10.1", refresh=10 ]
+roku:roku_tv:myplayer1 "My Roku TV" [ hostName="192.168.10.1", refresh=10 ]
+```
+
+roku.items:
+
+```java
+String Player_ActiveApp "Current App: [%s]" { channel="roku:roku_player:myplayer1:activeApp" }
+String Player_Button "Send Command to Roku" { channel="roku:roku_player:myplayer1:button" }
+String Player_PlayMode "Status: [%s]" { channel="roku:roku_player:myplayer1:playMode" }
+Number:Time Player_TimeElapsed "Elapsed Time: [%d %unit%]" { channel="roku:roku_player:myplayer1:timeElapsed" }
+Number:Time Player_TimeTotal "Total Time: [%d %unit%]" { channel="roku:roku_player:myplayer1:timeTotal" }
+```
+
+roku.sitemap:
+
+```perl
+sitemap roku label="Roku" {
+ Frame label="My Roku" {
+ Selection item=Player_ActiveApp icon="screen"
+ Selection item=Player_Button icon="screen"
+ Text item=Player_PlayMode
+ Text item=Player_TimeElapsed icon="time"
+ Text item=Player_TimeTotal icon="time"
+ }
+}
+```
diff --git a/bundles/org.openhab.binding.roku/pom.xml b/bundles/org.openhab.binding.roku/pom.xml
new file mode 100644
index 0000000000000..f1b29687ae5dd
--- /dev/null
+++ b/bundles/org.openhab.binding.roku/pom.xml
@@ -0,0 +1,17 @@
+
+
+
+ 4.0.0
+
+
+ org.openhab.addons.bundles
+ org.openhab.addons.reactor.bundles
+ 3.1.0-SNAPSHOT
+
+
+ org.openhab.binding.roku
+
+ openHAB Add-ons :: Bundles :: Roku Binding
+
+
diff --git a/bundles/org.openhab.binding.roku/src/main/feature/feature.xml b/bundles/org.openhab.binding.roku/src/main/feature/feature.xml
new file mode 100644
index 0000000000000..7d12deccc1bc4
--- /dev/null
+++ b/bundles/org.openhab.binding.roku/src/main/feature/feature.xml
@@ -0,0 +1,9 @@
+
+
+ mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features
+
+
+ openhab-runtime-base
+ mvn:org.openhab.addons.bundles/org.openhab.binding.roku/${project.version}
+
+
diff --git a/bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/RokuBindingConstants.java b/bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/RokuBindingConstants.java
new file mode 100644
index 0000000000000..76401a776f61e
--- /dev/null
+++ b/bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/RokuBindingConstants.java
@@ -0,0 +1,66 @@
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.roku.internal;
+
+import java.util.Set;
+
+import javax.measure.Unit;
+import javax.measure.quantity.Time;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.library.unit.Units;
+import org.openhab.core.thing.ThingTypeUID;
+
+/**
+ * The {@link RokuBindingConstants} class defines common constants, which are
+ * used across the whole binding.
+ *
+ * @author Michael Lobstein - Initial contribution
+ */
+@NonNullByDefault
+public class RokuBindingConstants {
+ public static final String BINDING_ID = "roku";
+ public static final String PROPERTY_UUID = "uuid";
+ public static final String PROPERTY_HOST_NAME = "hostName";
+ public static final String PROPERTY_PORT = "port";
+ public static final String PROPERTY_MODEL_NAME = "Model Name";
+ public static final String PROPERTY_MODEL_NUMBER = "Model Number";
+ public static final String PROPERTY_DEVICE_LOCAITON = "Device Location";
+ public static final String PROPERTY_SERIAL_NUMBER = "Serial Number";
+ public static final String PROPERTY_DEVICE_ID = "Device Id";
+ public static final String PROPERTY_SOFTWARE_VERSION = "Software Version";
+
+ // List of all Thing Type UIDs
+ public static final ThingTypeUID THING_TYPE_ROKU_PLAYER = new ThingTypeUID(BINDING_ID, "roku_player");
+ public static final ThingTypeUID THING_TYPE_ROKU_TV = new ThingTypeUID(BINDING_ID, "roku_tv");
+ public static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_ROKU_PLAYER,
+ THING_TYPE_ROKU_TV);
+
+ // List of all Channel id's
+ public static final String ACTIVE_APP = "activeApp";
+ public static final String BUTTON = "button";
+ public static final String PLAY_MODE = "playMode";
+ public static final String TIME_ELAPSED = "timeElapsed";
+ public static final String TIME_TOTAL = "timeTotal";
+
+ // Units of measurement of the data delivered by the API
+ public static final Unit API_SECONDS_UNIT = Units.SECOND;
+
+ public static final String STOP = "stop";
+ public static final String CLOSE = "close";
+ public static final String EMPTY = "";
+ public static final String ROKU_HOME = "Roku Home";
+ public static final String ROKU_HOME_ID = "-1";
+ public static final String ROKU_HOME_BUTTON = "Home";
+ public static final String NON_DIGIT_PATTERN = "[^\\d]";
+}
diff --git a/bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/RokuConfiguration.java b/bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/RokuConfiguration.java
new file mode 100644
index 0000000000000..933ba4240dc10
--- /dev/null
+++ b/bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/RokuConfiguration.java
@@ -0,0 +1,29 @@
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.roku.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * The {@link RokuConfiguration} is the class used to match the
+ * thing configuration.
+ *
+ * @author Michael Lobstein - Initial contribution
+ */
+@NonNullByDefault
+public class RokuConfiguration {
+ public @Nullable String hostName;
+ public Integer port = 8060;
+ public Integer refresh = 10;
+}
diff --git a/bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/RokuHandlerFactory.java b/bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/RokuHandlerFactory.java
new file mode 100644
index 0000000000000..7e9ec95cce983
--- /dev/null
+++ b/bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/RokuHandlerFactory.java
@@ -0,0 +1,67 @@
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.roku.internal;
+
+import static org.openhab.binding.roku.internal.RokuBindingConstants.SUPPORTED_THING_TYPES_UIDS;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.client.HttpClient;
+import org.openhab.binding.roku.internal.handler.RokuHandler;
+import org.openhab.core.io.net.http.HttpClientFactory;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.binding.BaseThingHandlerFactory;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.openhab.core.thing.binding.ThingHandlerFactory;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+
+/**
+ * The {@link RokuHandlerFactory} is responsible for creating things and thing
+ * handlers.
+ *
+ * @author Michael Lobstein - Initial contribution
+ */
+@NonNullByDefault
+@Component(service = ThingHandlerFactory.class, configurationPid = "binding.roku")
+public class RokuHandlerFactory extends BaseThingHandlerFactory {
+
+ private final HttpClient httpClient;
+ private final RokuStateDescriptionOptionProvider stateDescriptionProvider;
+
+ @Activate
+ public RokuHandlerFactory(final @Reference HttpClientFactory httpClientFactory,
+ final @Reference RokuStateDescriptionOptionProvider provider) {
+ this.httpClient = httpClientFactory.getCommonHttpClient();
+ this.stateDescriptionProvider = provider;
+ }
+
+ @Override
+ public boolean supportsThingType(ThingTypeUID thingTypeUID) {
+ return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
+ }
+
+ @Override
+ protected @Nullable ThingHandler createHandler(Thing thing) {
+ ThingTypeUID thingTypeUID = thing.getThingTypeUID();
+
+ if (SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID)) {
+ RokuHandler handler = new RokuHandler(thing, httpClient, stateDescriptionProvider);
+ return handler;
+ }
+
+ return null;
+ }
+}
diff --git a/bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/RokuHttpException.java b/bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/RokuHttpException.java
new file mode 100644
index 0000000000000..52faf291e944f
--- /dev/null
+++ b/bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/RokuHttpException.java
@@ -0,0 +1,29 @@
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.roku.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link RokuHttpException} extends Exception
+ *
+ * @author Michael Lobstein - Initial contribution
+ */
+@NonNullByDefault
+public class RokuHttpException extends Exception {
+ private static final long serialVersionUID = 1L;
+
+ public RokuHttpException(String errorMessage) {
+ super(errorMessage);
+ }
+}
diff --git a/bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/RokuStateDescriptionOptionProvider.java b/bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/RokuStateDescriptionOptionProvider.java
new file mode 100644
index 0000000000000..fef93a40ea165
--- /dev/null
+++ b/bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/RokuStateDescriptionOptionProvider.java
@@ -0,0 +1,41 @@
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.roku.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.thing.binding.BaseDynamicStateDescriptionProvider;
+import org.openhab.core.thing.i18n.ChannelTypeI18nLocalizationService;
+import org.openhab.core.thing.type.DynamicStateDescriptionProvider;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+
+/**
+ * Dynamic provider of state options while leaving other state description fields as original.
+ *
+ * @author Michael Lobstein - Initial contribution
+ */
+@Component(service = { DynamicStateDescriptionProvider.class, RokuStateDescriptionOptionProvider.class })
+@NonNullByDefault
+public class RokuStateDescriptionOptionProvider extends BaseDynamicStateDescriptionProvider {
+
+ @Reference
+ protected void setChannelTypeI18nLocalizationService(
+ final ChannelTypeI18nLocalizationService channelTypeI18nLocalizationService) {
+ this.channelTypeI18nLocalizationService = channelTypeI18nLocalizationService;
+ }
+
+ protected void unsetChannelTypeI18nLocalizationService(
+ final ChannelTypeI18nLocalizationService channelTypeI18nLocalizationService) {
+ this.channelTypeI18nLocalizationService = null;
+ }
+}
diff --git a/bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/communication/JAXBUtils.java b/bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/communication/JAXBUtils.java
new file mode 100644
index 0000000000000..1fee1bf347dbf
--- /dev/null
+++ b/bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/communication/JAXBUtils.java
@@ -0,0 +1,77 @@
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.roku.internal.communication;
+
+import javax.xml.bind.JAXBContext;
+import javax.xml.bind.JAXBException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.roku.internal.dto.ActiveApp;
+import org.openhab.binding.roku.internal.dto.Apps;
+import org.openhab.binding.roku.internal.dto.DeviceInfo;
+import org.openhab.binding.roku.internal.dto.Player;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Implementation for a static use of JAXBContext as singleton instance.
+ *
+ * @author Michael Lobstein - Initial contribution
+ */
+@NonNullByDefault
+public class JAXBUtils {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(JAXBUtils.class);
+
+ public static final @Nullable JAXBContext JAXBCONTEXT_ACTIVE_APP = initJAXBContextActiveApp();
+ public static final @Nullable JAXBContext JAXBCONTEXT_APPS = initJAXBContextApps();
+ public static final @Nullable JAXBContext JAXBCONTEXT_DEVICE_INFO = initJAXBContextDeviceInfo();
+ public static final @Nullable JAXBContext JAXBCONTEXT_PLAYER = initJAXBContextPlayer();
+
+ private static @Nullable JAXBContext initJAXBContextActiveApp() {
+ try {
+ return JAXBContext.newInstance(ActiveApp.class);
+ } catch (JAXBException e) {
+ LOGGER.error("Exception creating JAXBContext for active app: {}", e.getLocalizedMessage(), e);
+ return null;
+ }
+ }
+
+ private static @Nullable JAXBContext initJAXBContextApps() {
+ try {
+ return JAXBContext.newInstance(Apps.class);
+ } catch (JAXBException e) {
+ LOGGER.error("Exception creating JAXBContext for app list: {}", e.getLocalizedMessage(), e);
+ return null;
+ }
+ }
+
+ private static @Nullable JAXBContext initJAXBContextDeviceInfo() {
+ try {
+ return JAXBContext.newInstance(DeviceInfo.class);
+ } catch (JAXBException e) {
+ LOGGER.error("Exception creating JAXBContext for device info: {}", e.getLocalizedMessage(), e);
+ return null;
+ }
+ }
+
+ private static @Nullable JAXBContext initJAXBContextPlayer() {
+ try {
+ return JAXBContext.newInstance(Player.class);
+ } catch (JAXBException e) {
+ LOGGER.error("Exception creating JAXBContext for player info: {}", e.getLocalizedMessage(), e);
+ return null;
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/communication/RokuCommunicator.java b/bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/communication/RokuCommunicator.java
new file mode 100644
index 0000000000000..1c9eeb541d3c7
--- /dev/null
+++ b/bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/communication/RokuCommunicator.java
@@ -0,0 +1,210 @@
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.roku.internal.communication;
+
+import java.io.StringReader;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeoutException;
+
+import javax.xml.bind.JAXBContext;
+import javax.xml.bind.JAXBException;
+import javax.xml.bind.Unmarshaller;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.http.HttpMethod;
+import org.openhab.binding.roku.internal.RokuHttpException;
+import org.openhab.binding.roku.internal.dto.ActiveApp;
+import org.openhab.binding.roku.internal.dto.Apps;
+import org.openhab.binding.roku.internal.dto.Apps.App;
+import org.openhab.binding.roku.internal.dto.DeviceInfo;
+import org.openhab.binding.roku.internal.dto.Player;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Methods for accessing the HTTP interface of the Roku
+ *
+ * @author Michael Lobstein - Initial contribution
+ */
+@NonNullByDefault
+public class RokuCommunicator {
+ private final Logger logger = LoggerFactory.getLogger(RokuCommunicator.class);
+ private final HttpClient httpClient;
+
+ private final String urlKeyPress;
+ private final String urlLaunchApp;
+ private final String urlQryDevice;
+ private final String urlQryActiveApp;
+ private final String urlQryApps;
+ private final String urlQryPlayer;
+
+ public RokuCommunicator(HttpClient httpClient, String host, int port) {
+ this.httpClient = httpClient;
+
+ final String baseUrl = "http://" + host + ":" + port;
+ urlKeyPress = baseUrl + "/keypress/";
+ urlLaunchApp = baseUrl + "/launch/";
+ urlQryDevice = baseUrl + "/query/device-info";
+ urlQryActiveApp = baseUrl + "/query/active-app";
+ urlQryApps = baseUrl + "/query/apps";
+ urlQryPlayer = baseUrl + "/query/media-player";
+ }
+
+ /**
+ * Send a keypress command to the Roku
+ *
+ * @param key The key code to send
+ *
+ */
+ public void keyPress(String key) throws RokuHttpException {
+ postCommand(urlKeyPress + key);
+ }
+
+ /**
+ * Send a launch app command to the Roku
+ *
+ * @param appId The appId of the app to launch
+ *
+ */
+ public void launchApp(String appId) throws RokuHttpException {
+ postCommand(urlLaunchApp + appId);
+ }
+
+ /**
+ * Send a command to get device-info from the Roku and return a DeviceInfo object
+ *
+ * @return A DeviceInfo object populated with information about the connected Roku
+ * @throws RokuHttpException
+ */
+ public DeviceInfo getDeviceInfo() throws RokuHttpException {
+ try {
+ JAXBContext ctx = JAXBUtils.JAXBCONTEXT_DEVICE_INFO;
+ if (ctx != null) {
+ Unmarshaller unmarshaller = ctx.createUnmarshaller();
+ if (unmarshaller != null) {
+ DeviceInfo device = (DeviceInfo) unmarshaller.unmarshal(new StringReader(getCommand(urlQryDevice)));
+ if (device != null) {
+ return device;
+ }
+ }
+ }
+ throw new RokuHttpException("No DeviceInfo model in response");
+ } catch (JAXBException e) {
+ throw new RokuHttpException("Exception creating DeviceInfo Unmarshaller: " + e.getLocalizedMessage());
+ }
+ }
+
+ /**
+ * Send a command to get active-app from the Roku and return an ActiveApp object
+ *
+ * @return An ActiveApp object populated with information about the current running app on the Roku
+ * @throws RokuHttpException
+ */
+ public ActiveApp getActiveApp() throws RokuHttpException {
+ try {
+ JAXBContext ctx = JAXBUtils.JAXBCONTEXT_ACTIVE_APP;
+ if (ctx != null) {
+ Unmarshaller unmarshaller = ctx.createUnmarshaller();
+ if (unmarshaller != null) {
+ ActiveApp activeApp = (ActiveApp) unmarshaller
+ .unmarshal(new StringReader(getCommand(urlQryActiveApp)));
+ if (activeApp != null) {
+ return activeApp;
+ }
+ }
+ }
+ throw new RokuHttpException("No ActiveApp model in response");
+ } catch (JAXBException e) {
+ throw new RokuHttpException("Exception creating ActiveApp Unmarshaller: " + e.getLocalizedMessage());
+ }
+ }
+
+ /**
+ * Send a command to get the installed app list from the Roku and return a List of App objects
+ *
+ * @return A List of App objects for all apps currently installed on the Roku
+ * @throws RokuHttpException
+ */
+ public List getAppList() throws RokuHttpException {
+ try {
+ JAXBContext ctx = JAXBUtils.JAXBCONTEXT_APPS;
+ if (ctx != null) {
+ Unmarshaller unmarshaller = ctx.createUnmarshaller();
+ if (unmarshaller != null) {
+ Apps appList = (Apps) unmarshaller.unmarshal(new StringReader(getCommand(urlQryApps)));
+ if (appList != null) {
+ return appList.getApp();
+ }
+ }
+ }
+ throw new RokuHttpException("No AppList model in response");
+ } catch (JAXBException e) {
+ throw new RokuHttpException("Exception creating AppList Unmarshaller: " + e.getLocalizedMessage());
+ }
+ }
+
+ /**
+ * Send a command to get media-player from the Roku and return a Player object
+ *
+ * @return A Player object populated with information about the current stream playing on the Roku
+ * @throws RokuHttpException
+ */
+ public Player getPlayerInfo() throws RokuHttpException {
+ try {
+ JAXBContext ctx = JAXBUtils.JAXBCONTEXT_PLAYER;
+ if (ctx != null) {
+ Unmarshaller unmarshaller = ctx.createUnmarshaller();
+ if (unmarshaller != null) {
+ Player playerInfo = (Player) unmarshaller.unmarshal(new StringReader(getCommand(urlQryPlayer)));
+ if (playerInfo != null) {
+ return playerInfo;
+ }
+ }
+ }
+ throw new RokuHttpException("No Player info model in response");
+ } catch (JAXBException e) {
+ throw new RokuHttpException("Exception creating Player info Unmarshaller: " + e.getLocalizedMessage());
+ }
+ }
+
+ /**
+ * Sends a GET command to the Roku
+ *
+ * @param url The url to send with the command embedded in the URI
+ * @return The response content of the http request
+ */
+ private String getCommand(String url) {
+ try {
+ return httpClient.GET(url).getContentAsString();
+ } catch (InterruptedException | TimeoutException | ExecutionException e) {
+ logger.debug("Error executing player GET command, URL: {}, {} ", url, e.getMessage());
+ return "";
+ }
+ }
+
+ /**
+ * Sends a POST command to the Roku
+ *
+ * @param url The url to send with the command embedded in the URI
+ * @throws RokuHttpException
+ */
+ private void postCommand(String url) throws RokuHttpException {
+ try {
+ httpClient.POST(url).method(HttpMethod.POST).send();
+ } catch (InterruptedException | TimeoutException | ExecutionException e) {
+ throw new RokuHttpException("Error executing player POST command, URL: " + url + e.getMessage());
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/discovery/RokuDiscoveryService.java b/bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/discovery/RokuDiscoveryService.java
new file mode 100644
index 0000000000000..368cdbba62b48
--- /dev/null
+++ b/bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/discovery/RokuDiscoveryService.java
@@ -0,0 +1,264 @@
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.roku.internal.discovery;
+
+import static org.openhab.binding.roku.internal.RokuBindingConstants.*;
+
+import java.io.IOException;
+import java.net.DatagramPacket;
+import java.net.DatagramSocket;
+import java.net.Inet4Address;
+import java.net.InetAddress;
+import java.net.NetworkInterface;
+import java.net.SocketTimeoutException;
+import java.nio.charset.StandardCharsets;
+import java.util.Enumeration;
+import java.util.Scanner;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.client.HttpClient;
+import org.openhab.binding.roku.internal.RokuHttpException;
+import org.openhab.binding.roku.internal.communication.RokuCommunicator;
+import org.openhab.binding.roku.internal.dto.DeviceInfo;
+import org.openhab.core.config.discovery.AbstractDiscoveryService;
+import org.openhab.core.config.discovery.DiscoveryResult;
+import org.openhab.core.config.discovery.DiscoveryResultBuilder;
+import org.openhab.core.config.discovery.DiscoveryService;
+import org.openhab.core.io.net.http.HttpClientFactory;
+import org.openhab.core.thing.ThingUID;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link RokuDiscoveryService} is responsible for discovery of Roku devices on the local network
+ *
+ * @author William Welliver - Initial contribution
+ * @author Dan Cunningham - Refactoring and Improvements
+ * @author Michael Lobstein - Modified for Roku binding
+ */
+
+@NonNullByDefault
+@Component(service = DiscoveryService.class, configurationPid = "discovery.roku")
+public class RokuDiscoveryService extends AbstractDiscoveryService {
+ private final Logger logger = LoggerFactory.getLogger(RokuDiscoveryService.class);
+ private static final String ROKU_DISCOVERY_MESSAGE = "M-SEARCH * HTTP/1.1\r\n" + "Host: 239.255.255.250:1900\r\n"
+ + "Man: \"ssdp:discover\"\r\n" + "ST: roku:ecp\r\n" + "\r\n";
+
+ private static final Pattern USN_PATTERN = Pattern.compile("^(uuid:roku:)?ecp:([0-9a-zA-Z]{1,16})");
+
+ private static final Pattern IP_HOST_PATTERN = Pattern
+ .compile("([0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}):([0-9]{1,5})");
+
+ private static final String ROKU_SSDP_MATCH = "uuid:roku:ecp";
+ private static final int BACKGROUND_SCAN_INTERVAL_SECONDS = 300;
+
+ private final HttpClient httpClient;
+
+ private @Nullable ScheduledFuture> scheduledFuture;
+
+ @Activate
+ public RokuDiscoveryService(final @Reference HttpClientFactory httpClientFactory) {
+ super(SUPPORTED_THING_TYPES_UIDS, 30, true);
+ this.httpClient = httpClientFactory.getCommonHttpClient();
+ }
+
+ @Override
+ public void startBackgroundDiscovery() {
+ stopBackgroundDiscovery();
+ scheduledFuture = scheduler.scheduleWithFixedDelay(this::doNetworkScan, 0, BACKGROUND_SCAN_INTERVAL_SECONDS,
+ TimeUnit.SECONDS);
+ }
+
+ @Override
+ public void stopBackgroundDiscovery() {
+ ScheduledFuture> scheduledFuture = this.scheduledFuture;
+ if (scheduledFuture != null) {
+ scheduledFuture.cancel(true);
+ }
+ this.scheduledFuture = null;
+ }
+
+ @Override
+ public void startScan() {
+ doNetworkScan();
+ }
+
+ /**
+ * Enumerate all network interfaces, send the discovery broadcast and process responses.
+ *
+ */
+ private synchronized void doNetworkScan() {
+ try {
+ Enumeration nets = NetworkInterface.getNetworkInterfaces();
+ while (nets.hasMoreElements()) {
+ NetworkInterface ni = nets.nextElement();
+ try (DatagramSocket socket = sendDiscoveryBroacast(ni)) {
+ if (socket != null) {
+ scanResposesForKeywords(socket);
+ }
+ }
+ }
+ } catch (IOException e) {
+ logger.debug("Error discovering devices", e);
+ }
+ }
+
+ /**
+ * Broadcasts a SSDP discovery message into the network to find provided services.
+ *
+ * @return The Socket where answers to the discovery broadcast arrive
+ */
+ private @Nullable DatagramSocket sendDiscoveryBroacast(NetworkInterface ni) {
+ try {
+ InetAddress m = InetAddress.getByName("239.255.255.250");
+ final int port = 1900;
+
+ if (!ni.isUp() || !ni.supportsMulticast()) {
+ return null;
+ }
+
+ Enumeration addrs = ni.getInetAddresses();
+ InetAddress a = null;
+ while (addrs.hasMoreElements()) {
+ a = addrs.nextElement();
+ if (a instanceof Inet4Address) {
+ break;
+ } else {
+ a = null;
+ }
+ }
+ if (a == null) {
+ logger.debug("No ipv4 address on {}", ni.getName());
+ return null;
+ }
+
+ // Create the discovery message packet
+ byte[] requestMessage = ROKU_DISCOVERY_MESSAGE.getBytes(StandardCharsets.UTF_8);
+ DatagramPacket datagramPacket = new DatagramPacket(requestMessage, requestMessage.length, m, port);
+
+ // Create socket and send the discovery message
+ DatagramSocket socket = new DatagramSocket();
+ socket.setSoTimeout(3000);
+ socket.send(datagramPacket);
+ return socket;
+ } catch (IOException e) {
+ logger.debug("sendDiscoveryBroacast() got IOException: {}", e.getMessage());
+ return null;
+ }
+ }
+
+ /**
+ * Scans all messages that arrive on the socket and process those that come from a Roku.
+ *
+ * @param socket The socket where answers to the discovery broadcast arrive
+ */
+ private void scanResposesForKeywords(DatagramSocket socket) {
+ byte[] receiveData = new byte[1024];
+ do {
+ DatagramPacket packet = new DatagramPacket(receiveData, receiveData.length);
+ try {
+ socket.receive(packet);
+ } catch (SocketTimeoutException e) {
+ return;
+ } catch (IOException e) {
+ logger.debug("Got exception while trying to receive UPnP packets: {}", e.getMessage());
+ return;
+ }
+ String response = new String(packet.getData(), StandardCharsets.UTF_8);
+ if (response.contains(ROKU_SSDP_MATCH)) {
+ parseResponseCreateThing(response);
+ }
+ } while (true);
+ }
+
+ /**
+ * Process the response from the Roku into a DiscoveryResult.
+ *
+ */
+ private void parseResponseCreateThing(String response) {
+ DiscoveryResult result;
+
+ String label = "Roku";
+ String uuid = null;
+ String host = null;
+ int port = -1;
+
+ try (Scanner scanner = new Scanner(response)) {
+ while (scanner.hasNextLine()) {
+ String line = scanner.nextLine();
+ String[] pair = line.split(":", 2);
+ if (pair.length != 2) {
+ continue;
+ }
+
+ String key = pair[0].toLowerCase();
+ String value = pair[1].trim();
+ logger.debug("key: {} value: {}.", key, value);
+ switch (key) {
+ case "location":
+ host = value;
+ Matcher matchIp = IP_HOST_PATTERN.matcher(value);
+ if (matchIp.find()) {
+ host = matchIp.group(1);
+ port = Integer.parseInt(matchIp.group(2));
+ }
+ break;
+ case "usn":
+ Matcher matchUid = USN_PATTERN.matcher(value);
+ if (matchUid.find()) {
+ uuid = matchUid.group(2);
+ }
+ break;
+ default:
+ break;
+ }
+ }
+ }
+
+ if (host == null || port == -1 || uuid == null) {
+ logger.debug("Bad Format from Roku, received data was: {}", response);
+ return;
+ } else {
+ logger.debug("Found Roku, uuid: {} host: {}", uuid, host);
+ }
+
+ uuid = uuid.replace(":", "").toLowerCase();
+
+ ThingUID thingUid = new ThingUID(THING_TYPE_ROKU_PLAYER, uuid);
+
+ // Try to query the device using discovered host and port to get extended device info
+ try {
+ RokuCommunicator communicator = new RokuCommunicator(httpClient, host, port);
+ DeviceInfo device = communicator.getDeviceInfo();
+ label = device.getModelName() + " " + device.getModelNumber();
+ if (device.isTv()) {
+ thingUid = new ThingUID(THING_TYPE_ROKU_TV, uuid);
+ }
+ } catch (RokuHttpException e) {
+ logger.debug("Unable to retrieve Roku device-info. Exception: {}", e.getMessage(), e);
+ }
+
+ result = DiscoveryResultBuilder.create(thingUid).withLabel(label).withRepresentationProperty(PROPERTY_UUID)
+ .withProperty(PROPERTY_UUID, uuid).withProperty(PROPERTY_HOST_NAME, host)
+ .withProperty(PROPERTY_PORT, port).build();
+ this.thingDiscovered(result);
+ }
+}
diff --git a/bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/dto/ActiveApp.java b/bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/dto/ActiveApp.java
new file mode 100644
index 0000000000000..21d28e4f6c970
--- /dev/null
+++ b/bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/dto/ActiveApp.java
@@ -0,0 +1,149 @@
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.roku.internal.dto;
+
+import javax.xml.bind.annotation.XmlAccessType;
+import javax.xml.bind.annotation.XmlAccessorType;
+import javax.xml.bind.annotation.XmlAttribute;
+import javax.xml.bind.annotation.XmlElement;
+import javax.xml.bind.annotation.XmlRootElement;
+import javax.xml.bind.annotation.XmlValue;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Maps the XML response from the Roku HTTP endpoint '/query/active-app' (Active app info)
+ *
+ * @author Michael Lobstein - Initial contribution
+ */
+
+@NonNullByDefault
+@XmlAccessorType(XmlAccessType.FIELD)
+@XmlRootElement(name = "active-app")
+public class ActiveApp {
+ @XmlElement
+ private ActiveApp.App app = new App();
+
+ @XmlElement
+ private ActiveApp.Screensaver screensaver = new Screensaver();
+
+ public ActiveApp.App getApp() {
+ return app;
+ }
+
+ public void setApp(ActiveApp.App value) {
+ this.app = value;
+ }
+
+ public ActiveApp.Screensaver getScreensaver() {
+ return screensaver;
+ }
+
+ public void setScreensaver(ActiveApp.Screensaver value) {
+ this.screensaver = value;
+ }
+
+ @XmlAccessorType(XmlAccessType.FIELD)
+ public static class App {
+ @XmlValue
+ private String value = "";
+
+ @XmlAttribute(name = "id")
+ private String id = "-1";
+
+ @XmlAttribute(name = "type")
+ private String type = "";
+
+ @XmlAttribute(name = "version")
+ private String version = "";
+
+ public String getValue() {
+ return value;
+ }
+
+ public void setValue(String value) {
+ this.value = value;
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public void setId(String value) {
+ this.id = value;
+ }
+
+ public String getType() {
+ return type;
+ }
+
+ public void setType(String value) {
+ this.type = value;
+ }
+
+ public String getVersion() {
+ return version;
+ }
+
+ public void setVersion(String value) {
+ this.version = value;
+ }
+ }
+
+ @XmlAccessorType(XmlAccessType.FIELD)
+ public static class Screensaver {
+ @XmlValue
+ private String value = "";
+
+ @XmlAttribute(name = "id")
+ private int id = -1;
+
+ @XmlAttribute(name = "type")
+ private String type = "";
+
+ @XmlAttribute(name = "version")
+ private String version = "";
+
+ public String getValue() {
+ return value;
+ }
+
+ public void setValue(String value) {
+ this.value = value;
+ }
+
+ public int getId() {
+ return id;
+ }
+
+ public void setId(int value) {
+ this.id = value;
+ }
+
+ public String getType() {
+ return type;
+ }
+
+ public void setType(String value) {
+ this.type = value;
+ }
+
+ public String getVersion() {
+ return version;
+ }
+
+ public void setVersion(String value) {
+ this.version = value;
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/dto/Apps.java b/bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/dto/Apps.java
new file mode 100644
index 0000000000000..282e0c94bdcbc
--- /dev/null
+++ b/bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/dto/Apps.java
@@ -0,0 +1,90 @@
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.roku.internal.dto;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.xml.bind.annotation.XmlAccessType;
+import javax.xml.bind.annotation.XmlAccessorType;
+import javax.xml.bind.annotation.XmlAttribute;
+import javax.xml.bind.annotation.XmlElement;
+import javax.xml.bind.annotation.XmlRootElement;
+import javax.xml.bind.annotation.XmlValue;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Maps the XML response from the Roku HTTP endpoint '/query/apps' (List of installed apps)
+ *
+ * @author Michael Lobstein - Initial contribution
+ */
+
+@NonNullByDefault
+@XmlAccessorType(XmlAccessType.FIELD)
+@XmlRootElement(name = "apps")
+public class Apps {
+ @XmlElement
+ private List app = new ArrayList();
+
+ public List getApp() {
+ return this.app;
+ }
+
+ @XmlAccessorType(XmlAccessType.FIELD)
+ public static class App {
+ @XmlValue
+ private String value = "";
+
+ @XmlAttribute(name = "id")
+ private String id = "-1";
+
+ @XmlAttribute(name = "type")
+ private String type = "";
+
+ @XmlAttribute(name = "version")
+ private String version = "";
+
+ public String getValue() {
+ return value;
+ }
+
+ public void setValue(String value) {
+ this.value = value;
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public void setId(String value) {
+ this.id = value;
+ }
+
+ public String getType() {
+ return type;
+ }
+
+ public void setType(String value) {
+ this.type = value;
+ }
+
+ public String getVersion() {
+ return version;
+ }
+
+ public void setVersion(String value) {
+ this.version = value;
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/dto/DeviceInfo.java b/bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/dto/DeviceInfo.java
new file mode 100644
index 0000000000000..fd921d3e79009
--- /dev/null
+++ b/bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/dto/DeviceInfo.java
@@ -0,0 +1,662 @@
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.roku.internal.dto;
+
+import javax.xml.bind.annotation.XmlAccessType;
+import javax.xml.bind.annotation.XmlAccessorType;
+import javax.xml.bind.annotation.XmlElement;
+import javax.xml.bind.annotation.XmlRootElement;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Maps the XML response from the Roku HTTP endpoint '/query/device-info' (Device information)
+ *
+ * @author Michael Lobstein - Initial contribution
+ */
+
+@NonNullByDefault
+@XmlAccessorType(XmlAccessType.FIELD)
+@XmlRootElement(name = "device-info")
+public class DeviceInfo {
+ @XmlElement(name = "udn")
+ private String udn = "";
+ @XmlElement(name = "serial-number")
+ private String serialNumber = "";
+ @XmlElement(name = "device-id")
+ private String deviceId = "";
+ @XmlElement(name = "advertising-id")
+ private String advertisingId = "";
+ @XmlElement(name = "vendor-name")
+ private String vendorName = "";
+ @XmlElement(name = "model-name")
+ private String modelName = "";
+ @XmlElement(name = "model-number")
+ private String modelNumber = "";
+ @XmlElement(name = "model-region")
+ private String modelRegion = "";
+ @XmlElement(name = "is-tv")
+ private boolean isTv = false;
+ @XmlElement(name = "is-stick")
+ private boolean isStick = false;
+ @XmlElement(name = "ui-resolution")
+ private String uiResolution = "";
+ @XmlElement(name = "supports-ethernet")
+ private boolean supportsEthernet = false;
+ @XmlElement(name = "wifi-mac")
+ private String wifiMac = "";
+ @XmlElement(name = "wifi-driver")
+ private String wifiDriver = "";
+ @XmlElement(name = "has-wifi-extender")
+ private boolean hasWifiExtender = false;
+ @XmlElement(name = "has-wifi-5G-support")
+ private boolean hasWifi5GSupport = false;
+ @XmlElement(name = "can-use-wifi-extender")
+ private boolean canUseWifiExtender = false;
+ @XmlElement(name = "ethernet-mac")
+ private String ethernetMac = "";
+ @XmlElement(name = "network-type")
+ private String networkType = "";
+ @XmlElement(name = "friendly-device-name")
+ private String friendlyDeviceName = "";
+ @XmlElement(name = "friendly-model-name")
+ private String friendlyModelName = "";
+ @XmlElement(name = "default-device-name")
+ private String defaultDeviceName = "";
+ @XmlElement(name = "user-device-name")
+ private String userDeviceName = "";
+ @XmlElement(name = "user-device-location")
+ private String userDeviceLocation = "";
+ @XmlElement(name = "build-number")
+ private String buildNumber = "";
+ @XmlElement(name = "software-version")
+ private String softwareVersion = "";
+ @XmlElement(name = "software-build")
+ private String softwareBuild = "";
+ @XmlElement(name = "secure-device")
+ private boolean secureDevice = false;
+ @XmlElement(name = "language")
+ private String language = "";
+ @XmlElement(name = "country")
+ private String country = "";
+ @XmlElement(name = "locale")
+ private String locale = "";
+ @XmlElement(name = "time-zone-auto")
+ private boolean timeZoneAuto = false;
+ @XmlElement(name = "time-zone")
+ private String timeZone = "";
+ @XmlElement(name = "time-zone-name")
+ private String timeZoneName = "";
+ @XmlElement(name = "time-zone-tz")
+ private String timeZoneTz = "";
+ @XmlElement(name = "time-zone-offset")
+ private int timeZoneOffset = 0;
+ @XmlElement(name = "clock-format")
+ private String clockFormat = "";
+ @XmlElement(name = "uptime")
+ private int uptime = 0;
+ @XmlElement(name = "power-mode")
+ private String powerMode = "";
+ @XmlElement(name = "supports-suspend")
+ private boolean supportsSuspend = false;
+ @XmlElement(name = "supports-find-remote")
+ private boolean supportsFindRemote = false;
+ @XmlElement(name = "find-remote-is-possible")
+ private boolean findRemoteIsPossible = false;
+ @XmlElement(name = "supports-audio-guide")
+ private boolean supportsAudioGuide = false;
+ @XmlElement(name = "supports-rva")
+ private boolean supportsRva = false;
+ @XmlElement(name = "developer-enabled")
+ private boolean developerEnabled = false;
+ @XmlElement(name = "keyed-developer-id")
+ private String keyedDeveloperId = "";
+ @XmlElement(name = "search-enabled")
+ private boolean searchEnabled = false;
+ @XmlElement(name = "search-channels-enabled")
+ private boolean searchChannelsEnabled = false;
+ @XmlElement(name = "voice-search-enabled")
+ private boolean voiceSearchEnabled = false;
+ @XmlElement(name = "notifications-enabled")
+ private boolean notificationsEnabled = false;
+ @XmlElement(name = "notifications-first-use")
+ private boolean notificationsFirstUse = false;
+ @XmlElement(name = "supports-private-listening")
+ private boolean supportsPrivateListening = false;
+ @XmlElement(name = "headphones-connected")
+ private boolean headphonesConnected = false;
+ @XmlElement(name = "supports-ecs-textedit")
+ private boolean supportsEcsTextedit = false;
+ @XmlElement(name = "supports-ecs-microphone")
+ private boolean supportsEcsMicrophone = false;
+ @XmlElement(name = "supports-wake-on-wlan")
+ private boolean supportsWakeOnWlan = false;
+ @XmlElement(name = "has-play-on-roku")
+ private boolean hasPlayOnRoku = false;
+ @XmlElement(name = "has-mobile-screensaver")
+ private boolean hasMobileScreensaver = false;
+ @XmlElement(name = "support-url")
+ private String supportUrl = "";
+ @XmlElement(name = "grandcentral-version")
+ private String grandcentralVersion = "";
+ @XmlElement(name = "trc-version")
+ private String trcVersion = "";
+ @XmlElement(name = "trc-channel-version")
+ private String trcChannelVersion = "";
+ @XmlElement(name = "davinci-version")
+ private String davinciVersion = "";
+
+ public String getUdn() {
+ return udn;
+ }
+
+ public void setUdn(String value) {
+ this.udn = value;
+ }
+
+ public String getSerialNumber() {
+ return serialNumber;
+ }
+
+ public void setSerialNumber(String value) {
+ this.serialNumber = value;
+ }
+
+ public String getDeviceId() {
+ return deviceId;
+ }
+
+ public void setDeviceId(String value) {
+ this.deviceId = value;
+ }
+
+ public String getAdvertisingId() {
+ return advertisingId;
+ }
+
+ public void setAdvertisingId(String value) {
+ this.advertisingId = value;
+ }
+
+ public String getVendorName() {
+ return vendorName;
+ }
+
+ public void setVendorName(String value) {
+ this.vendorName = value;
+ }
+
+ public String getModelName() {
+ return modelName;
+ }
+
+ public void setModelName(String value) {
+ this.modelName = value;
+ }
+
+ public String getModelNumber() {
+ return modelNumber;
+ }
+
+ public void setModelNumber(String value) {
+ this.modelNumber = value;
+ }
+
+ public String getModelRegion() {
+ return modelRegion;
+ }
+
+ public void setModelRegion(String value) {
+ this.modelRegion = value;
+ }
+
+ public boolean isTv() {
+ return isTv;
+ }
+
+ public void setIsTv(boolean value) {
+ this.isTv = value;
+ }
+
+ public boolean isStick() {
+ return isStick;
+ }
+
+ public void setIsStick(boolean value) {
+ this.isStick = value;
+ }
+
+ public String getUiResolution() {
+ return uiResolution;
+ }
+
+ public void setUiResolution(String value) {
+ this.uiResolution = value;
+ }
+
+ public boolean isSupportsEthernet() {
+ return supportsEthernet;
+ }
+
+ public void setSupportsEthernet(boolean value) {
+ this.supportsEthernet = value;
+ }
+
+ public String getWifiMac() {
+ return wifiMac;
+ }
+
+ public void setWifiMac(String value) {
+ this.wifiMac = value;
+ }
+
+ public String getWifiDriver() {
+ return wifiDriver;
+ }
+
+ public void setWifiDriver(String value) {
+ this.wifiDriver = value;
+ }
+
+ public boolean isHasWifiExtender() {
+ return hasWifiExtender;
+ }
+
+ public void setHasWifiExtender(boolean value) {
+ this.hasWifiExtender = value;
+ }
+
+ public boolean isHasWifi5GSupport() {
+ return hasWifi5GSupport;
+ }
+
+ public void setHasWifi5GSupport(boolean value) {
+ this.hasWifi5GSupport = value;
+ }
+
+ public boolean isCanUseWifiExtender() {
+ return canUseWifiExtender;
+ }
+
+ public void setCanUseWifiExtender(boolean value) {
+ this.canUseWifiExtender = value;
+ }
+
+ public String getEthernetMac() {
+ return ethernetMac;
+ }
+
+ public void setEthernetMac(String value) {
+ this.ethernetMac = value;
+ }
+
+ public String getNetworkType() {
+ return networkType;
+ }
+
+ public void setNetworkType(String value) {
+ this.networkType = value;
+ }
+
+ public String getFriendlyDeviceName() {
+ return friendlyDeviceName;
+ }
+
+ public void setFriendlyDeviceName(String value) {
+ this.friendlyDeviceName = value;
+ }
+
+ public String getFriendlyModelName() {
+ return friendlyModelName;
+ }
+
+ public void setFriendlyModelName(String value) {
+ this.friendlyModelName = value;
+ }
+
+ public String getDefaultDeviceName() {
+ return defaultDeviceName;
+ }
+
+ public void setDefaultDeviceName(String value) {
+ this.defaultDeviceName = value;
+ }
+
+ public String getUserDeviceName() {
+ return userDeviceName;
+ }
+
+ public void setUserDeviceName(String value) {
+ this.userDeviceName = value;
+ }
+
+ public String getUserDeviceLocation() {
+ return userDeviceLocation;
+ }
+
+ public void setUserDeviceLocation(String value) {
+ this.userDeviceLocation = value;
+ }
+
+ public String getBuildNumber() {
+ return buildNumber;
+ }
+
+ public void setBuildNumber(String value) {
+ this.buildNumber = value;
+ }
+
+ public String getSoftwareVersion() {
+ return softwareVersion;
+ }
+
+ public void setSoftwareVersion(String value) {
+ this.softwareVersion = value;
+ }
+
+ public String getSoftwareBuild() {
+ return softwareBuild;
+ }
+
+ public void setSoftwareBuild(String value) {
+ this.softwareBuild = value;
+ }
+
+ public boolean isSecureDevice() {
+ return secureDevice;
+ }
+
+ public void setSecureDevice(boolean value) {
+ this.secureDevice = value;
+ }
+
+ public String getLanguage() {
+ return language;
+ }
+
+ public void setLanguage(String value) {
+ this.language = value;
+ }
+
+ public String getCountry() {
+ return country;
+ }
+
+ public void setCountry(String value) {
+ this.country = value;
+ }
+
+ public String getLocale() {
+ return locale;
+ }
+
+ public void setLocale(String value) {
+ this.locale = value;
+ }
+
+ public boolean isTimeZoneAuto() {
+ return timeZoneAuto;
+ }
+
+ public void setTimeZoneAuto(boolean value) {
+ this.timeZoneAuto = value;
+ }
+
+ public String getTimeZone() {
+ return timeZone;
+ }
+
+ public void setTimeZone(String value) {
+ this.timeZone = value;
+ }
+
+ public String getTimeZoneName() {
+ return timeZoneName;
+ }
+
+ public void setTimeZoneName(String value) {
+ this.timeZoneName = value;
+ }
+
+ public String getTimeZoneTz() {
+ return timeZoneTz;
+ }
+
+ public void setTimeZoneTz(String value) {
+ this.timeZoneTz = value;
+ }
+
+ public int getTimeZoneOffset() {
+ return timeZoneOffset;
+ }
+
+ public void setTimeZoneOffset(int value) {
+ this.timeZoneOffset = value;
+ }
+
+ public String getClockFormat() {
+ return clockFormat;
+ }
+
+ public void setClockFormat(String value) {
+ this.clockFormat = value;
+ }
+
+ public int getUptime() {
+ return uptime;
+ }
+
+ public void setUptime(int value) {
+ this.uptime = value;
+ }
+
+ public String getPowerMode() {
+ return powerMode;
+ }
+
+ public void setPowerMode(String value) {
+ this.powerMode = value;
+ }
+
+ public boolean isSupportsSuspend() {
+ return supportsSuspend;
+ }
+
+ public void setSupportsSuspend(boolean value) {
+ this.supportsSuspend = value;
+ }
+
+ public boolean isSupportsFindRemote() {
+ return supportsFindRemote;
+ }
+
+ public void setSupportsFindRemote(boolean value) {
+ this.supportsFindRemote = value;
+ }
+
+ public boolean isFindRemoteIsPossible() {
+ return findRemoteIsPossible;
+ }
+
+ public void setFindRemoteIsPossible(boolean value) {
+ this.findRemoteIsPossible = value;
+ }
+
+ public boolean isSupportsAudioGuide() {
+ return supportsAudioGuide;
+ }
+
+ public void setSupportsAudioGuide(boolean value) {
+ this.supportsAudioGuide = value;
+ }
+
+ public boolean isSupportsRva() {
+ return supportsRva;
+ }
+
+ public void setSupportsRva(boolean value) {
+ this.supportsRva = value;
+ }
+
+ public boolean isDeveloperEnabled() {
+ return developerEnabled;
+ }
+
+ public void setDeveloperEnabled(boolean value) {
+ this.developerEnabled = value;
+ }
+
+ public String getKeyedDeveloperId() {
+ return keyedDeveloperId;
+ }
+
+ public void setKeyedDeveloperId(String value) {
+ this.keyedDeveloperId = value;
+ }
+
+ public boolean isSearchEnabled() {
+ return searchEnabled;
+ }
+
+ public void setSearchEnabled(boolean value) {
+ this.searchEnabled = value;
+ }
+
+ public boolean isSearchChannelsEnabled() {
+ return searchChannelsEnabled;
+ }
+
+ public void setSearchChannelsEnabled(boolean value) {
+ this.searchChannelsEnabled = value;
+ }
+
+ public boolean isVoiceSearchEnabled() {
+ return voiceSearchEnabled;
+ }
+
+ public void setVoiceSearchEnabled(boolean value) {
+ this.voiceSearchEnabled = value;
+ }
+
+ public boolean isNotificationsEnabled() {
+ return notificationsEnabled;
+ }
+
+ public void setNotificationsEnabled(boolean value) {
+ this.notificationsEnabled = value;
+ }
+
+ public boolean isNotificationsFirstUse() {
+ return notificationsFirstUse;
+ }
+
+ public void setNotificationsFirstUse(boolean value) {
+ this.notificationsFirstUse = value;
+ }
+
+ public boolean isSupportsPrivateListening() {
+ return supportsPrivateListening;
+ }
+
+ public void setSupportsPrivateListening(boolean value) {
+ this.supportsPrivateListening = value;
+ }
+
+ public boolean isHeadphonesConnected() {
+ return headphonesConnected;
+ }
+
+ public void setHeadphonesConnected(boolean value) {
+ this.headphonesConnected = value;
+ }
+
+ public boolean isSupportsEcsTextedit() {
+ return supportsEcsTextedit;
+ }
+
+ public void setSupportsEcsTextedit(boolean value) {
+ this.supportsEcsTextedit = value;
+ }
+
+ public boolean isSupportsEcsMicrophone() {
+ return supportsEcsMicrophone;
+ }
+
+ public void setSupportsEcsMicrophone(boolean value) {
+ this.supportsEcsMicrophone = value;
+ }
+
+ public boolean isSupportsWakeOnWlan() {
+ return supportsWakeOnWlan;
+ }
+
+ public void setSupportsWakeOnWlan(boolean value) {
+ this.supportsWakeOnWlan = value;
+ }
+
+ public boolean isHasPlayOnRoku() {
+ return hasPlayOnRoku;
+ }
+
+ public void setHasPlayOnRoku(boolean value) {
+ this.hasPlayOnRoku = value;
+ }
+
+ public boolean isHasMobileScreensaver() {
+ return hasMobileScreensaver;
+ }
+
+ public void setHasMobileScreensaver(boolean value) {
+ this.hasMobileScreensaver = value;
+ }
+
+ public String getSupportUrl() {
+ return supportUrl;
+ }
+
+ public void setSupportUrl(String value) {
+ this.supportUrl = value;
+ }
+
+ public String getGrandcentralVersion() {
+ return grandcentralVersion;
+ }
+
+ public void setGrandcentralVersion(String value) {
+ this.grandcentralVersion = value;
+ }
+
+ public String getTrcVersion() {
+ return trcVersion;
+ }
+
+ public void setTrcVersion(String value) {
+ this.trcVersion = value;
+ }
+
+ public String getTrcChannelVersion() {
+ return trcChannelVersion;
+ }
+
+ public void setTrcChannelVersion(String value) {
+ this.trcChannelVersion = value;
+ }
+
+ public String getDavinciVersion() {
+ return davinciVersion;
+ }
+
+ public void setDavinciVersion(String value) {
+ this.davinciVersion = value;
+ }
+}
diff --git a/bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/dto/Player.java b/bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/dto/Player.java
new file mode 100644
index 0000000000000..7444c9777b3ed
--- /dev/null
+++ b/bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/dto/Player.java
@@ -0,0 +1,380 @@
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.roku.internal.dto;
+
+import javax.xml.bind.annotation.XmlAccessType;
+import javax.xml.bind.annotation.XmlAccessorType;
+import javax.xml.bind.annotation.XmlAttribute;
+import javax.xml.bind.annotation.XmlElement;
+import javax.xml.bind.annotation.XmlRootElement;
+import javax.xml.bind.annotation.XmlType;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Maps the XML response from the Roku HTTP endpoint '/query/media-player' (Current stream playback meta-data)
+ *
+ * @author Michael Lobstein - Initial contribution
+ */
+
+@NonNullByDefault
+@XmlAccessorType(XmlAccessType.FIELD)
+@XmlRootElement(name = "player")
+public class Player {
+ @XmlElement(name = "plugin")
+ private Player.Plugin plugin = new Plugin();
+
+ @XmlElement(name = "format")
+ private Player.Format format = new Format();
+
+ @XmlElement(name = "buffering")
+ private Player.Buffering buffering = new Buffering();
+
+ @XmlElement(name = "new_stream")
+ private Player.NewStream newStream = new NewStream();
+
+ @XmlElement(name = "position")
+ private String position = "";
+
+ @XmlElement(name = "duration")
+ private String duration = "";
+
+ @XmlElement(name = "is_live")
+ private boolean isLive = false;
+
+ @XmlElement(name = "runtime")
+ private String runtime = "";
+
+ @XmlElement(name = "stream_segment")
+ private Player.StreamSegment streamSegment = new StreamSegment();
+
+ @XmlAttribute(name = "error")
+ private Boolean error = false;
+
+ @XmlAttribute(name = "state")
+ private String state = "";
+
+ public Player.Plugin getPlugin() {
+ return plugin;
+ }
+
+ public void setPlugin(Player.Plugin value) {
+ this.plugin = value;
+ }
+
+ public Player.Format getFormat() {
+ return format;
+ }
+
+ public void setFormat(Player.Format value) {
+ this.format = value;
+ }
+
+ public Player.Buffering getBuffering() {
+ return buffering;
+ }
+
+ public void setBuffering(Player.Buffering value) {
+ this.buffering = value;
+ }
+
+ public Player.NewStream getNewStream() {
+ return newStream;
+ }
+
+ public void setNewStream(Player.NewStream value) {
+ this.newStream = value;
+ }
+
+ public String getPosition() {
+ return position;
+ }
+
+ public void setPosition(String value) {
+ this.position = value;
+ }
+
+ public String getDuration() {
+ return duration;
+ }
+
+ public void setDuration(String value) {
+ this.duration = value;
+ }
+
+ public boolean isIsLive() {
+ return isLive;
+ }
+
+ public void setIsLive(boolean value) {
+ this.isLive = value;
+ }
+
+ public String getRuntime() {
+ return runtime;
+ }
+
+ public void setRuntime(String value) {
+ this.runtime = value;
+ }
+
+ public Player.StreamSegment getStreamSegment() {
+ return streamSegment;
+ }
+
+ public void setStreamSegment(Player.StreamSegment value) {
+ this.streamSegment = value;
+ }
+
+ public Boolean isError() {
+ return error;
+ }
+
+ public void setError(Boolean value) {
+ this.error = value;
+ }
+
+ public String getState() {
+ return state;
+ }
+
+ public void setState(String value) {
+ this.state = value;
+ }
+
+ @XmlAccessorType(XmlAccessType.FIELD)
+ public static class Buffering {
+ @XmlAttribute(name = "current")
+ private int current = -1;
+
+ @XmlAttribute(name = "max")
+ private int max = -1;
+
+ @XmlAttribute(name = "target")
+ private int target = -1;
+
+ public int getCurrent() {
+ return current;
+ }
+
+ public void setCurrent(int value) {
+ this.current = value;
+ }
+
+ public int getMax() {
+ return max;
+ }
+
+ public void setMax(int value) {
+ this.max = value;
+ }
+
+ public int getTarget() {
+ return target;
+ }
+
+ public void setTarget(int value) {
+ this.target = value;
+ }
+ }
+
+ @XmlAccessorType(XmlAccessType.FIELD)
+ @XmlType(name = "")
+ public static class Format {
+ @XmlAttribute(name = "audio")
+ private String audio = "";
+
+ @XmlAttribute(name = "captions")
+ private String captions = "";
+
+ @XmlAttribute(name = "container")
+ private String container = "";
+
+ @XmlAttribute(name = "drm")
+ private String drm = "";
+
+ @XmlAttribute(name = "video")
+ private String video = "";
+
+ @XmlAttribute(name = "video_res")
+ private String videoRes = "";
+
+ public String getAudio() {
+ return audio;
+ }
+
+ public void setAudio(String value) {
+ this.audio = value;
+ }
+
+ public String getCaptions() {
+ return captions;
+ }
+
+ public void setCaptions(String value) {
+ this.captions = value;
+ }
+
+ public String getContainer() {
+ return container;
+ }
+
+ public void setContainer(String value) {
+ this.container = value;
+ }
+
+ public String getDrm() {
+ return drm;
+ }
+
+ public void setDrm(String value) {
+ this.drm = value;
+ }
+
+ public String getVideo() {
+ return video;
+ }
+
+ public void setVideo(String value) {
+ this.video = value;
+ }
+
+ public String getVideoRes() {
+ return videoRes;
+ }
+
+ public void setVideoRes(String value) {
+ this.videoRes = value;
+ }
+ }
+
+ @XmlAccessorType(XmlAccessType.FIELD)
+ public static class NewStream {
+ @XmlAttribute(name = "speed")
+ private String speed = "";
+
+ public String getSpeed() {
+ return speed;
+ }
+
+ public void setSpeed(String value) {
+ this.speed = value;
+ }
+ }
+
+ @XmlAccessorType(XmlAccessType.FIELD)
+ public static class Plugin {
+ @XmlAttribute(name = "bandwidth")
+ private String bandwidth = "";
+
+ @XmlAttribute(name = "id")
+ private int id = -1;
+
+ @XmlAttribute(name = "name")
+ private String name = "";
+
+ public String getBandwidth() {
+ return bandwidth;
+ }
+
+ public void setBandwidth(String value) {
+ this.bandwidth = value;
+ }
+
+ public int getId() {
+ return id;
+ }
+
+ public void setId(int value) {
+ this.id = value;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String value) {
+ this.name = value;
+ }
+ }
+
+ @XmlAccessorType(XmlAccessType.FIELD)
+ @XmlType(name = "")
+ public static class StreamSegment {
+ @XmlAttribute(name = "bitrate")
+ private int bitrate = -1;
+
+ @XmlAttribute(name = "height")
+ private int height = -1;
+
+ @XmlAttribute(name = "media_sequence")
+ private int mediaSequence = -1;
+
+ @XmlAttribute(name = "segment_type")
+ private String segmentType = "";
+
+ @XmlAttribute(name = "time")
+ private int time = -1;
+
+ @XmlAttribute(name = "width")
+ private int width = -1;
+
+ public int getBitrate() {
+ return bitrate;
+ }
+
+ public void setBitrate(int value) {
+ this.bitrate = value;
+ }
+
+ public int getHeight() {
+ return height;
+ }
+
+ public void setHeight(int value) {
+ this.height = value;
+ }
+
+ public int getMediaSequence() {
+ return mediaSequence;
+ }
+
+ public void setMediaSequence(int value) {
+ this.mediaSequence = value;
+ }
+
+ public String getSegmentType() {
+ return segmentType;
+ }
+
+ public void setSegmentType(String value) {
+ this.segmentType = value;
+ }
+
+ public int getTime() {
+ return time;
+ }
+
+ public void setTime(int value) {
+ this.time = value;
+ }
+
+ public int getWidth() {
+ return width;
+ }
+
+ public void setWidth(int value) {
+ this.width = value;
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/handler/RokuHandler.java b/bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/handler/RokuHandler.java
new file mode 100644
index 0000000000000..340bca12ae691
--- /dev/null
+++ b/bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/handler/RokuHandler.java
@@ -0,0 +1,247 @@
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.roku.internal.handler;
+
+import static org.openhab.binding.roku.internal.RokuBindingConstants.*;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.client.HttpClient;
+import org.openhab.binding.roku.internal.RokuConfiguration;
+import org.openhab.binding.roku.internal.RokuHttpException;
+import org.openhab.binding.roku.internal.RokuStateDescriptionOptionProvider;
+import org.openhab.binding.roku.internal.communication.RokuCommunicator;
+import org.openhab.binding.roku.internal.dto.ActiveApp;
+import org.openhab.binding.roku.internal.dto.Apps.App;
+import org.openhab.binding.roku.internal.dto.DeviceInfo;
+import org.openhab.binding.roku.internal.dto.Player;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.binding.BaseThingHandler;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.RefreshType;
+import org.openhab.core.types.StateOption;
+import org.openhab.core.types.UnDefType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link RokuHandler} is responsible for handling commands, which are
+ * sent to one of the channels.
+ *
+ * @author Michael Lobstein - Initial contribution
+ */
+@NonNullByDefault
+public class RokuHandler extends BaseThingHandler {
+ private static final int DEFAULT_REFRESH_PERIOD_SEC = 10;
+
+ private final Logger logger = LoggerFactory.getLogger(RokuHandler.class);
+ private final HttpClient httpClient;
+ private final RokuStateDescriptionOptionProvider stateDescriptionProvider;
+
+ private @Nullable ScheduledFuture> refreshJob;
+ private @Nullable ScheduledFuture> appListJob;
+
+ private RokuCommunicator communicator;
+ private DeviceInfo deviceInfo = new DeviceInfo();
+ private int refreshInterval = DEFAULT_REFRESH_PERIOD_SEC;
+
+ private Object sequenceLock = new Object();
+
+ public RokuHandler(Thing thing, HttpClient httpClient,
+ RokuStateDescriptionOptionProvider stateDescriptionProvider) {
+ super(thing);
+ this.httpClient = httpClient;
+ this.stateDescriptionProvider = stateDescriptionProvider;
+ this.communicator = new RokuCommunicator(httpClient, EMPTY, -1);
+ }
+
+ @Override
+ public void initialize() {
+ logger.debug("Initializing Roku handler");
+ RokuConfiguration config = getConfigAs(RokuConfiguration.class);
+
+ final @Nullable String host = config.hostName;
+
+ if (host != null && !EMPTY.equals(host)) {
+ this.communicator = new RokuCommunicator(httpClient, host, config.port);
+ } else {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Host Name must be specified");
+ return;
+ }
+
+ if (config.refresh >= 10) {
+ refreshInterval = config.refresh;
+ }
+
+ updateStatus(ThingStatus.UNKNOWN);
+
+ try {
+ deviceInfo = communicator.getDeviceInfo();
+ thing.setProperty(PROPERTY_MODEL_NAME, deviceInfo.getModelName());
+ thing.setProperty(PROPERTY_MODEL_NUMBER, deviceInfo.getModelNumber());
+ thing.setProperty(PROPERTY_DEVICE_LOCAITON, deviceInfo.getUserDeviceLocation());
+ thing.setProperty(PROPERTY_SERIAL_NUMBER, deviceInfo.getSerialNumber());
+ thing.setProperty(PROPERTY_DEVICE_ID, deviceInfo.getDeviceId());
+ thing.setProperty(PROPERTY_SOFTWARE_VERSION, deviceInfo.getSoftwareVersion());
+ updateStatus(ThingStatus.ONLINE);
+ } catch (RokuHttpException e) {
+ logger.debug("Unable to retrieve Roku device-info. Exception: {}", e.getMessage(), e);
+ }
+ startAutomaticRefresh();
+ startAppListRefresh();
+ }
+
+ /**
+ * Start the job to periodically get status updates from the Roku
+ */
+ private void startAutomaticRefresh() {
+ ScheduledFuture> refreshJob = this.refreshJob;
+ if (refreshJob == null || refreshJob.isCancelled()) {
+ this.refreshJob = scheduler.scheduleWithFixedDelay(this::refreshPlayerState, 0, refreshInterval,
+ TimeUnit.SECONDS);
+ }
+ }
+
+ /**
+ * Get a status update from the Roku and update the channels
+ */
+ private void refreshPlayerState() {
+ synchronized (sequenceLock) {
+ try {
+ ActiveApp activeApp = communicator.getActiveApp();
+ updateState(ACTIVE_APP, new StringType(activeApp.getApp().getId()));
+ updateStatus(ThingStatus.ONLINE);
+ } catch (RokuHttpException e) {
+ logger.debug("Unable to retrieve Roku active-app info. Exception: {}", e.getMessage(), e);
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
+ }
+
+ try {
+ Player playerInfo = communicator.getPlayerInfo();
+ // When nothing playing, 'close' is reported, replace with 'stop'
+ updateState(PLAY_MODE, new StringType(playerInfo.getState().replaceAll(CLOSE, STOP)));
+
+ // Remove non-numeric from string, ie: ' ms'
+ String position = playerInfo.getPosition().replaceAll(NON_DIGIT_PATTERN, EMPTY);
+ if (!EMPTY.equals(position)) {
+ updateState(TIME_ELAPSED, new QuantityType<>(Integer.parseInt(position) / 1000, API_SECONDS_UNIT));
+ } else {
+ updateState(TIME_ELAPSED, UnDefType.UNDEF);
+ }
+
+ String duration = playerInfo.getDuration().replaceAll(NON_DIGIT_PATTERN, EMPTY);
+ if (!EMPTY.equals(duration)) {
+ updateState(TIME_TOTAL, new QuantityType<>(Integer.parseInt(duration) / 1000, API_SECONDS_UNIT));
+ } else {
+ updateState(TIME_TOTAL, UnDefType.UNDEF);
+ }
+ } catch (RokuHttpException e) {
+ logger.debug("Unable to retrieve Roku media-player info. Exception: {}", e.getMessage(), e);
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
+ }
+ }
+ }
+
+ /**
+ * Start the job to periodically update list of apps installed on the the Roku
+ */
+ private void startAppListRefresh() {
+ ScheduledFuture> appListJob = this.appListJob;
+ if (appListJob == null || appListJob.isCancelled()) {
+ this.appListJob = scheduler.scheduleWithFixedDelay(this::refreshAppList, 10, 600, TimeUnit.SECONDS);
+ }
+ }
+
+ /**
+ * Update the dropdown that lists all apps installed on the Roku
+ */
+ private void refreshAppList() {
+ synchronized (sequenceLock) {
+ try {
+ List appList = communicator.getAppList();
+
+ List appListOptions = new ArrayList<>();
+ // Roku Home will be selected in the drop-down any time an app is not running.
+ appListOptions.add(new StateOption(ROKU_HOME_ID, ROKU_HOME));
+
+ appList.forEach(app -> {
+ appListOptions.add(new StateOption(app.getId(), app.getValue()));
+ });
+
+ stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), ACTIVE_APP),
+ appListOptions);
+
+ } catch (RokuHttpException e) {
+ logger.debug("Unable to retrieve Roku installed app-list. Exception: {}", e.getMessage(), e);
+ }
+ }
+ }
+
+ @Override
+ public void dispose() {
+ ScheduledFuture> refreshJob = this.refreshJob;
+ if (refreshJob != null) {
+ refreshJob.cancel(true);
+ this.refreshJob = null;
+ }
+
+ ScheduledFuture> appListJob = this.appListJob;
+ if (appListJob != null) {
+ appListJob.cancel(true);
+ this.appListJob = null;
+ }
+ }
+
+ @Override
+ public void handleCommand(ChannelUID channelUID, Command command) {
+ if (command instanceof RefreshType) {
+ logger.debug("Unsupported refresh command: {}", command);
+ } else if (channelUID.getId().equals(BUTTON)) {
+ synchronized (sequenceLock) {
+ try {
+ communicator.keyPress(command.toString());
+ } catch (RokuHttpException e) {
+ logger.debug("Unable to send keypress to Roku, key: {}, Exception: {}", command, e.getMessage());
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
+ }
+ }
+ } else if (channelUID.getId().equals(ACTIVE_APP)) {
+ synchronized (sequenceLock) {
+ try {
+ String appId = command.toString();
+ // Roku Home(-1) is not a real appId, just press the home button instead
+ if (!ROKU_HOME_ID.equals(appId)) {
+ communicator.launchApp(appId);
+ } else {
+ communicator.keyPress(ROKU_HOME_BUTTON);
+ }
+ } catch (RokuHttpException e) {
+ logger.debug("Unable to launch app on Roku, appId: {}, Exception: {}", command, e.getMessage());
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
+ }
+ }
+ } else {
+ logger.debug("Unsupported command: {}", command);
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.roku/src/main/resources/OH-INF/binding/binding.xml b/bundles/org.openhab.binding.roku/src/main/resources/OH-INF/binding/binding.xml
new file mode 100644
index 0000000000000..c801dbe417708
--- /dev/null
+++ b/bundles/org.openhab.binding.roku/src/main/resources/OH-INF/binding/binding.xml
@@ -0,0 +1,9 @@
+
+
+
+ Roku Binding
+ Controls Roku Streaming Media Players and TVs
+
+
diff --git a/bundles/org.openhab.binding.roku/src/main/resources/OH-INF/config/config.xml b/bundles/org.openhab.binding.roku/src/main/resources/OH-INF/config/config.xml
new file mode 100644
index 0000000000000..c69acf8ea8834
--- /dev/null
+++ b/bundles/org.openhab.binding.roku/src/main/resources/OH-INF/config/config.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+ network-address
+ Host Name/IP Address
+ Host Name or IP Address of the Roku device
+
+
+ Port
+ Port for the ECP Connector of the Roku device
+ 8060
+ true
+
+
+ Refresh Interval
+ Specifies the Refresh Interval in Seconds
+ 10
+ s
+
+
+
diff --git a/bundles/org.openhab.binding.roku/src/main/resources/OH-INF/thing/roku.xml b/bundles/org.openhab.binding.roku/src/main/resources/OH-INF/thing/roku.xml
new file mode 100644
index 0000000000000..1f3458d602fb0
--- /dev/null
+++ b/bundles/org.openhab.binding.roku/src/main/resources/OH-INF/thing/roku.xml
@@ -0,0 +1,156 @@
+
+
+
+
+
+ Roku
+
+ A Roku Streaming Media Player
+
+
+
+
+
+
+
+
+
+
+
+ unknown
+ unknown
+ unknown
+ unknown
+ unknown
+ unknown
+
+
+ uuid
+
+
+
+
+
+
+ Roku TV
+
+ A Roku Streaming Media TV
+
+
+
+
+
+
+
+
+
+
+
+ unknown
+ unknown
+ unknown
+ unknown
+ unknown
+ unknown
+
+
+ uuid
+
+
+
+
+
+ String
+ Remote Button
+ A Remote Button Press to Send to the Roku
+
+
+ Home
+ Reverse
+ Forward
+ Play
+ Select
+ Left
+ Right
+ Down
+ Up
+ Back
+ Instant Replay
+ Info
+ Backspace
+ Search
+ Enter
+ Find Remote
+
+
+
+
+
+ String
+ Remote Button
+ A Remote Button Press to Send to the Roku TV
+
+
+ Home
+ Reverse
+ Forward
+ Play
+ Select
+ Left
+ Right
+ Down
+ Up
+ Back
+ Instant Replay
+ Info
+ Backspace
+ Search
+ Enter
+ Find Remote
+ Volume Up
+ Volume Down
+ Volume Mute
+ Channel Up
+ Channel Down
+ Input Tuner
+ Input HDMI1
+ Input HDMI2
+ Input HDMI3
+ Input HDMI4
+ Input AV1
+ Power Off
+
+
+
+
+
+ String
+ Active App
+ The Currently Running App on the Roku
+
+
+
+ String
+ Play Mode
+ The Current Playback Mode
+
+
+
+
+ Number:Time
+ Playback Time
+ The Current Playback Time Elapsed
+
+
+
+
+ Number:Time
+ Total Time
+ The Total Length of the Current Title
+
+
+
+
diff --git a/bundles/pom.xml b/bundles/pom.xml
index b8b7bdfc76c47..2f868ea433442 100644
--- a/bundles/pom.xml
+++ b/bundles/pom.xml
@@ -488,6 +488,8 @@
org.openhab.binding.robonect
+ org.openhab.binding.roku
+
org.openhab.binding.rotel
org.openhab.binding.russound