]> git.basschouten.com Git - openhab-addons.git/commitdiff
[roku] binding - initial implementation (#9571)
authormlobstein <michael.lobstein@gmail.com>
Mon, 18 Jan 2021 20:57:42 +0000 (14:57 -0600)
committerGitHub <noreply@github.com>
Mon, 18 Jan 2021 20:57:42 +0000 (12:57 -0800)
* Roku binding - initial implementation
* update channel names to camelCase
* review changes
* spelling
* update README.md

Signed-off-by: Michael Lobstein <michael.lobstein@gmail.com>
23 files changed:
CODEOWNERS
bom/openhab-addons/pom.xml
bundles/org.openhab.binding.roku/NOTICE [new file with mode: 0644]
bundles/org.openhab.binding.roku/README.md [new file with mode: 0644]
bundles/org.openhab.binding.roku/pom.xml [new file with mode: 0644]
bundles/org.openhab.binding.roku/src/main/feature/feature.xml [new file with mode: 0644]
bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/RokuBindingConstants.java [new file with mode: 0644]
bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/RokuConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/RokuHandlerFactory.java [new file with mode: 0644]
bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/RokuHttpException.java [new file with mode: 0644]
bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/RokuStateDescriptionOptionProvider.java [new file with mode: 0644]
bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/communication/JAXBUtils.java [new file with mode: 0644]
bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/communication/RokuCommunicator.java [new file with mode: 0644]
bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/discovery/RokuDiscoveryService.java [new file with mode: 0644]
bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/dto/ActiveApp.java [new file with mode: 0644]
bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/dto/Apps.java [new file with mode: 0644]
bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/dto/DeviceInfo.java [new file with mode: 0644]
bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/dto/Player.java [new file with mode: 0644]
bundles/org.openhab.binding.roku/src/main/java/org/openhab/binding/roku/internal/handler/RokuHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.roku/src/main/resources/OH-INF/binding/binding.xml [new file with mode: 0644]
bundles/org.openhab.binding.roku/src/main/resources/OH-INF/config/config.xml [new file with mode: 0644]
bundles/org.openhab.binding.roku/src/main/resources/OH-INF/thing/roku.xml [new file with mode: 0644]
bundles/pom.xml

index 870d7f28d76cf40ed4deead15801a0ee5163ab58..868649530d67760c2b6823430a24a4d223157754 100644 (file)
 /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
index c15457a389fad21ed43c0641fd07e4907e1392bf..3c733884952ee41388bf011e3d764726120a9700 100644 (file)
       <artifactId>org.openhab.binding.robonect</artifactId>
       <version>${project.version}</version>
     </dependency>
+    <dependency>
+      <groupId>org.openhab.addons.bundles</groupId>
+      <artifactId>org.openhab.binding.roku</artifactId>
+      <version>${project.version}</version>
+    </dependency>
     <dependency>
       <groupId>org.openhab.addons.bundles</groupId>
       <artifactId>org.openhab.binding.rotel</artifactId>
diff --git a/bundles/org.openhab.binding.roku/NOTICE b/bundles/org.openhab.binding.roku/NOTICE
new file mode 100644 (file)
index 0000000..38d625e
--- /dev/null
@@ -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 (file)
index 0000000..b3d92cc
--- /dev/null
@@ -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 (file)
index 0000000..f1b2968
--- /dev/null
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+
+  <modelVersion>4.0.0</modelVersion>
+
+  <parent>
+    <groupId>org.openhab.addons.bundles</groupId>
+    <artifactId>org.openhab.addons.reactor.bundles</artifactId>
+    <version>3.1.0-SNAPSHOT</version>
+  </parent>
+
+  <artifactId>org.openhab.binding.roku</artifactId>
+
+  <name>openHAB Add-ons :: Bundles :: Roku Binding</name>
+
+</project>
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 (file)
index 0000000..7d12dec
--- /dev/null
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<features name="org.openhab.binding.roku-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
+       <repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
+
+       <feature name="openhab-binding-roku" description="Roku Binding" version="${project.version}">
+               <feature>openhab-runtime-base</feature>
+               <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.roku/${project.version}</bundle>
+       </feature>
+</features>
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 (file)
index 0000000..76401a7
--- /dev/null
@@ -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<ThingTypeUID> 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<Time> 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 (file)
index 0000000..933ba42
--- /dev/null
@@ -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 (file)
index 0000000..7e9ec95
--- /dev/null
@@ -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 (file)
index 0000000..52faf29
--- /dev/null
@@ -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 (file)
index 0000000..fef93a4
--- /dev/null
@@ -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 (file)
index 0000000..1fee1bf
--- /dev/null
@@ -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 (file)
index 0000000..1c9eeb5
--- /dev/null
@@ -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<App> 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 (file)
index 0000000..368cdbb
--- /dev/null
@@ -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<NetworkInterface> 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<InetAddress> 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 (file)
index 0000000..21d28e4
--- /dev/null
@@ -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 (file)
index 0000000..282e0c9
--- /dev/null
@@ -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<Apps.App> app = new ArrayList<Apps.App>();
+
+    public List<Apps.App> 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 (file)
index 0000000..fd921d3
--- /dev/null
@@ -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 (file)
index 0000000..7444c97
--- /dev/null
@@ -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 (file)
index 0000000..340bca1
--- /dev/null
@@ -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<App> appList = communicator.getAppList();
+
+                List<StateOption> 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 (file)
index 0000000..c801dbe
--- /dev/null
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<binding:binding id="roku" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:binding="https://openhab.org/schemas/binding/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/binding/v1.0.0 https://openhab.org/schemas/binding-1.0.0.xsd">
+
+       <name>Roku Binding</name>
+       <description>Controls Roku Streaming Media Players and TVs</description>
+
+</binding:binding>
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 (file)
index 0000000..c69acf8
--- /dev/null
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<config-description:config-descriptions
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:config-description="https://openhab.org/schemas/config-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0 https://openhab.org/schemas/config-description-1.0.0.xsd">
+
+       <config-description uri="thing-type:roku:rokuconfig">
+               <parameter name="hostName" type="text" required="true">
+                       <context>network-address</context>
+                       <label>Host Name/IP Address</label>
+                       <description>Host Name or IP Address of the Roku device</description>
+               </parameter>
+               <parameter name="port" type="integer" min="1" max="65535" required="true">
+                       <label>Port</label>
+                       <description>Port for the ECP Connector of the Roku device</description>
+                       <default>8060</default>
+                       <advanced>true</advanced>
+               </parameter>
+               <parameter name="refresh" type="integer" min="10" required="false" unit="s">
+                       <label>Refresh Interval</label>
+                       <description>Specifies the Refresh Interval in Seconds</description>
+                       <default>10</default>
+                       <unitLabel>s</unitLabel>
+               </parameter>
+       </config-description>
+</config-description:config-descriptions>
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 (file)
index 0000000..1f3458d
--- /dev/null
@@ -0,0 +1,156 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="roku"
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+       <!-- Roku Player Thing -->
+       <thing-type id="roku_player">
+               <label>Roku</label>
+               <description>
+                       A Roku Streaming Media Player
+               </description>
+
+               <channels>
+                       <channel id="activeApp" typeId="activeApp"/>
+                       <channel id="button" typeId="button"/>
+                       <channel id="playMode" typeId="playMode"/>
+                       <channel id="timeElapsed" typeId="timeElapsed"/>
+                       <channel id="timeTotal" typeId="timeTotal"/>
+               </channels>
+
+               <properties>
+                       <property name="Model Name">unknown</property>
+                       <property name="Model Number">unknown</property>
+                       <property name="Device Location">unknown</property>
+                       <property name="Serial Number">unknown</property>
+                       <property name="Device Id">unknown</property>
+                       <property name="Software Version">unknown</property>
+               </properties>
+
+               <representation-property>uuid</representation-property>
+
+               <config-description-ref uri="thing-type:roku:rokuconfig"/>
+       </thing-type>
+
+       <!-- Roku TV Thing -->
+       <thing-type id="roku_tv">
+               <label>Roku TV</label>
+               <description>
+                       A Roku Streaming Media TV
+               </description>
+
+               <channels>
+                       <channel id="activeApp" typeId="activeApp"/>
+                       <channel id="button" typeId="buttonTv"/>
+                       <channel id="playMode" typeId="playMode"/>
+                       <channel id="timeElapsed" typeId="timeElapsed"/>
+                       <channel id="timeTotal" typeId="timeTotal"/>
+               </channels>
+
+               <properties>
+                       <property name="Model Name">unknown</property>
+                       <property name="Model Number">unknown</property>
+                       <property name="Device Location">unknown</property>
+                       <property name="Serial Number">unknown</property>
+                       <property name="Device Id">unknown</property>
+                       <property name="Software Version">unknown</property>
+               </properties>
+
+               <representation-property>uuid</representation-property>
+
+               <config-description-ref uri="thing-type:roku:rokuconfig"/>
+       </thing-type>
+
+       <channel-type id="button">
+               <item-type>String</item-type>
+               <label>Remote Button</label>
+               <description>A Remote Button Press to Send to the Roku</description>
+               <state>
+                       <options>
+                               <option value="Home">Home</option>
+                               <option value="Rev">Reverse</option>
+                               <option value="Fwd">Forward</option>
+                               <option value="Play">Play</option>
+                               <option value="Select">Select</option>
+                               <option value="Left">Left</option>
+                               <option value="Right">Right</option>
+                               <option value="Down">Down</option>
+                               <option value="Up">Up</option>
+                               <option value="Back">Back</option>
+                               <option value="InstantReplay">Instant Replay</option>
+                               <option value="Info">Info</option>
+                               <option value="Backspace">Backspace</option>
+                               <option value="Search">Search</option>
+                               <option value="Enter">Enter</option>
+                               <option value="FindRemote">Find Remote</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="buttonTv">
+               <item-type>String</item-type>
+               <label>Remote Button</label>
+               <description>A Remote Button Press to Send to the Roku TV</description>
+               <state>
+                       <options>
+                               <option value="Home">Home</option>
+                               <option value="Rev">Reverse</option>
+                               <option value="Fwd">Forward</option>
+                               <option value="Play">Play</option>
+                               <option value="Select">Select</option>
+                               <option value="Left">Left</option>
+                               <option value="Right">Right</option>
+                               <option value="Down">Down</option>
+                               <option value="Up">Up</option>
+                               <option value="Back">Back</option>
+                               <option value="InstantReplay">Instant Replay</option>
+                               <option value="Info">Info</option>
+                               <option value="Backspace">Backspace</option>
+                               <option value="Search">Search</option>
+                               <option value="Enter">Enter</option>
+                               <option value="FindRemote">Find Remote</option>
+                               <option value="VolumeUp">Volume Up</option>
+                               <option value="VolumeDown">Volume Down</option>
+                               <option value="VolumeMute">Volume Mute</option>
+                               <option value="ChannelUp">Channel Up</option>
+                               <option value="Channel Down">Channel Down</option>
+                               <option value="InputTuner">Input Tuner</option>
+                               <option value="InputHDMI1">Input HDMI1</option>
+                               <option value="InputHDMI2">Input HDMI2</option>
+                               <option value="InputHDMI3">Input HDMI3</option>
+                               <option value="InputHDMI4">Input HDMI4</option>
+                               <option value="InputAV1">Input AV1</option>
+                               <option value="PowerOff">Power Off</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="activeApp">
+               <item-type>String</item-type>
+               <label>Active App</label>
+               <description>The Currently Running App on the Roku</description>
+       </channel-type>
+
+       <channel-type id="playMode">
+               <item-type>String</item-type>
+               <label>Play Mode</label>
+               <description>The Current Playback Mode</description>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="timeElapsed">
+               <item-type>Number:Time</item-type>
+               <label>Playback Time</label>
+               <description>The Current Playback Time Elapsed</description>
+               <state readOnly="true" pattern="%d %unit%"/>
+       </channel-type>
+
+       <channel-type id="timeTotal">
+               <item-type>Number:Time</item-type>
+               <label>Total Time</label>
+               <description>The Total Length of the Current Title</description>
+               <state readOnly="true" pattern="%d %unit%"/>
+       </channel-type>
+
+</thing:thing-descriptions>
index fcd79be957a2f45b64e2b80ced893b13da51bee6..f557f63b034da739a0569a264f318dd565d065a7 100644 (file)
     <module>org.openhab.binding.rfxcom</module>
     <module>org.openhab.binding.rme</module>
     <module>org.openhab.binding.robonect</module>
+    <module>org.openhab.binding.roku</module>
     <module>org.openhab.binding.rotel</module>
     <module>org.openhab.binding.russound</module>
     <module>org.openhab.binding.sagercaster</module>