Roku (HFN) Plugin

This is a plugin for Roku that is based on Bill’s Roku plugin originally posted April 5, 2020 in the old forum. Briefly the main changes are:

  • various code cleanups/stylistic changes to match some other plugins I’ve written
  • support for keyboard commands per the Roku documentation (including using some concepts from the MCE Controller Plugin I wrote)
  • support for querying the active app via device.InputSource (note that screensaver state is not indicated, although that could be added if desired)
  • support for more media commands and input commands not supported by all Roku devices, such as commands used by Roku TV

The Roku’s API is documented here:

The plugin has 1 setting that can be configured: Host. Host should be set to the Roku’s IP address. The Roku will not actually respond to commands sent to its hostname, but only it’s IP address, for whatever reason. For example, commands to “http://roku.mynetwork.com/” will be ignored whereas commands to “http://192.168.1.1/” will work, even though “roku.mynetwork.com” resolves to “192.168.1.1”.

Last updated: 2020-09-17
Roku-HFN-2020-09-17.plugin (11.9 KB)

plugin.Name = "Roku";
plugin.OnChangeRequest = onChangeRequest;
plugin.OnConnect = onConnect;
plugin.OnDisconnect = onDisconnect;
plugin.OnPoll = onPoll;
plugin.OnSynchronizeDevices = onSynchronizeDevices;
plugin.PollingInterval = 5000;
plugin.DefaultSettings = {
    "Host"  : "192.168.1.1",    // this needs to be an IP address and not a hostname or the Roku won't respond
};

var VERSION = "2020-09-17";

/*
    This is based on Bill Venhaus's Roku_Plugin posted on April 5, 2020,
      available at:
        https://groups.google.com/d/msg/thehomeremote/t1_lODTwmqA/7LCheBHSAAAJ

    Modifications made include:
    - various code cleanups/stylistic changes to match some other plugins I've
      written, but the core of the work is still Bill's
    - support for keyboard commands per the Roku documentation (including using
      some concepts from the MCE Controller Plugin I wrote)
    - support for querying the active app, which completes support for the Media
      Input Source capability (except does not indicate whether the screensaver
      is on, although that could be added)
    - support for more media commands and inputs not supported by all Roku
      devices, such as commands used by Roku TV
*/

var http = new HTTPClient();

var apps = null;

// This is used to track the up/down state of the shift key.
var shiftKeyState = false;

var mediaCommandMappings = {
    "DirectionUp"       : "Up",
    "DirectionLeft"     : "Left",
    "DirectionDown"     : "Down",
    "DirectionRight"    : "Right",
    "Select"            : "Select",
    "Back"              : "Back",
    "Info"              : "Info",
    "Home"              : "Home",

    "Play"              : "Play",
    "Pause"             : "Pause",
    "Rewind"            : "Rev",
    "FastForward"       : "Fwd",

    "InstantReplay"     : "InstantReplay",
    "Search"            : "Search",

    "PowerOff"          : "PowerOff",   // these are only supported by some devices, such as Roku TV
    "PowerOn"           : "PowerOn",
    "PowerToggle"       : "Power",
    "VolumeUp"          : "VolumeUp",
    "VolumeDown"        : "VolumeDown",
    "Mute"              : "VolumeMute",
    "ChannelUp"         : "ChannelUp",
    "ChannelDown"       : "ChannelDown",
    "FindRemote"        : "FindRemote",

    "Enter"             : "Select",      // technically, Roku supports an Enter command different than
    "LetterEnter"       : "Select",      // Select, but testing showed Select was more intuitive for this
    
    "Number1"           : "Lit_1",
    "Number2"           : "Lit_2",
    "Number3"           : "Lit_3",
    "Number4"           : "Lit_4",
    "Number5"           : "Lit_5",
    "Number6"           : "Lit_6",
    "Number7"           : "Lit_7",
    "Number8"           : "Lit_8",
    "Number9"           : "Lit_9",
    "Number0"           : "Lit_0",

    "LetterEscape"      : "Back",
    "LetterBackspace"   : "Backspace",
};

var mediaCommandShiftMappings = {
    "LetterShift"       : "shift",
    "LetterShiftOn"     : "shift",
    "LetterShiftOff"    : "shift",
};

var mediaCommandKeyMappings = {
    "LetterA"           : [ "Lit_a", "Lit_A" ],
    "LetterB"           : [ "Lit_b", "Lit_B" ],
    "LetterC"           : [ "Lit_c", "Lit_C" ],
    "LetterD"           : [ "Lit_d", "Lit_D" ],
    "LetterE"           : [ "Lit_e", "Lit_E" ],
    "LetterF"           : [ "Lit_f", "Lit_F" ],
    "LetterG"           : [ "Lit_g", "Lit_G" ],
    "LetterH"           : [ "Lit_h", "Lit_H" ],
    "LetterI"           : [ "Lit_i", "Lit_I" ],
    "LetterJ"           : [ "Lit_j", "Lit_J" ],
    "LetterK"           : [ "Lit_k", "Lit_K" ],
    "LetterL"           : [ "Lit_l", "Lit_L" ],
    "LetterM"           : [ "Lit_m", "Lit_M" ],
    "LetterN"           : [ "Lit_n", "Lit_N" ],
    "LetterO"           : [ "Lit_o", "Lit_O" ],
    "LetterP"           : [ "Lit_p", "Lit_P" ],
    "LetterQ"           : [ "Lit_q", "Lit_Q" ],
    "LetterR"           : [ "Lit_r", "Lit_R" ],
    "LetterS"           : [ "Lit_s", "Lit_S" ],
    "LetterT"           : [ "Lit_t", "Lit_T" ],
    "LetterU"           : [ "Lit_u", "Lit_U" ],
    "LetterV"           : [ "Lit_v", "Lit_V" ],
    "LetterW"           : [ "Lit_w", "Lit_W" ],
    "LetterX"           : [ "Lit_x", "Lit_X" ],
    "LetterY"           : [ "Lit_y", "Lit_Y" ],
    "LetterZ"           : [ "Lit_z", "Lit_Z" ],

    "Letter1"           : [ "Lit_1", "Lit_!" ],
    "Letter2"           : [ "Lit_2", "Lit_%40" /* @ */ ],
    "Letter3"           : [ "Lit_3", "Lit_%23" /* # */ ],
    "Letter4"           : [ "Lit_4", "Lit_%24" /* $ */ ],
    "Letter5"           : [ "Lit_5", "Lit_%" ],
    "Letter6"           : [ "Lit_6", "Lit_^" ],
    "Letter7"           : [ "Lit_7", "Lit_%26" /* & */ ],
    "Letter8"           : [ "Lit_8", "Lit_*" ],
    "Letter9"           : [ "Lit_9", "Lit_(" ],
    "Letter0"           : [ "Lit_0", "Lit_)" ],

    "LetterTab"         : [ "Lit_%09" /* <tab> */, "Lit_%09" /* <tab> */ ],
    "LetterSpace"       : [ "Lit_%20" /* <space> */, "Lit_%20" /* <space> */ ],

    "LetterBackQuote"   : [ "Lit_`", "Lit_~" ],
    "LetterHyphen"      : [ "Lit_-", "Lit__" ],
    "LetterEqual"       : [ "Lit_%3d" /* = */, "Lit_%2b" /* + */ ],

    "LetterLeftSquareBracket"   : [ "Lit_[", "Lit_{" ],
    "LetterRightSquareBracket"  : [ "Lit_]", "Lit_}" ],
    "LetterBackslash"   : [ "Lit_%5c" /* \ */, "Lit_|" ],

    "LetterSemicolon"   : [ "Lit_%3b" /* ; */, "Lit_%3a" /* : */ ],
    "LetterSingleQuote" : [ "Lit_\'", "Lit_\"" ],

    "LetterComma"       : [ "Lit_%2c" /* , */, "Lit_<" ],
    "LetterPeriod"      : [ "Lit_.", "Lit_>" ],
    "LetterSlash"       : [ "Lit_%2f" /* / */, "Lit_%3f" /* ? */ ],
};

var inputSourceStaticMappings = {
    "InputTuner"        : "InputTuner",
    "InputHDMI1"        : "InputHDMI1",
    "InputHDMI2"        : "InputHDMI2",
    "InputHDMI3"        : "InputHDMI3",
    "InputHDMI4"        : "InputHDMI4",
    "InputAV1"          : "InputAV1",
};


// helper function to get the current active app
function rokuGetActiveApp() {
    var host = plugin.Settings["Host"];
    var device = plugin.Devices[plugin.Name];

    if(device) {
        var activeResponse = http.get("http://" + host + ":8060/query/active-app", { responseType: 'xml' });
        var activeElement = activeResponse.data;

        activeElement.elements.forEach(function (node) {
            // find the app node (usually the only one, but there may also be a screensaver)
            if(node.name == "app") {
                device.InputSource = node.text;
            }
        });
    }

    // all done
}

function onChangeRequest(device, attribute, value) {
    console.log("onChangeRequest called...");

    var host = plugin.Settings["Host"];

    switch(attribute) {
        case "MediaCommand":

            var mc = "";

            if(mediaCommandMappings.hasOwnProperty(value)) {
                console.log("  standard HR command identified: " + value);
    
                // standard commands have no special processing needed
                mc = mediaCommandMappings[value];

            } else if(mediaCommandShiftMappings.hasOwnProperty(value)) {
                console.log("  shift command identified: " + value);

                // because apparently we don't have endsWith()...
                if(value.slice(-2) == "On") {
                    shiftKeyState = true;

                } else if(value.slice(-3) == "Off") {
                    shiftKeyState = false;

                } else {
                    // toggle  shift state
                    shiftKeyState = !shiftKeyState;
                }

                console.log("  shift state is now " + shiftKeyState);

            } else if(mediaCommandKeyMappings.hasOwnProperty(value)) {
                console.log("  key command identified: " + value);

                // get the correct key value based on the current shift state
                if(shiftKeyState) {
                    mc = mediaCommandKeyMappings[value][1];
                } else {
                    mc = mediaCommandKeyMappings[value][0];
                }

            }  else {
                // unknown command
                console.log("ERROR: unsupported command: " + value);
            }
    
            // send the command, if there is one
            if(mc) {
                console.log("  sending keypress command: " + mc);

                http.post("http://" + host + ":8060/keypress/" + mc);
            }

            break;

        case "InputSource":
            if(inputSourceStaticMappings.hasOwnProperty(value)) {
                console.log("  fixed input name: " + value);

                // static inputs are treated as keypresses
                http.post("http://" + host + ":8060/keypress/" + inputSourceStaticMappings[value]);

            } else {
                console.log("  app input name:" + value);

                // apps are launched
                http.post("http://" + host + ":8060/launch/" + apps[value]);
            }

            break;

        default:
            console.log("ERROR: unsupported command type: " + attribute);
            break;
    }
}

function onConnect() {
    console.log("onConnect called...");

    var host = plugin.Settings["Host"];

    console.log("  Host: " + host);

    var device = plugin.Devices[plugin.Name];

    if(device) {
        // get available apps if we don't have it
        if(apps == null) {
            console.log("  building apps list...");
            var appsResponse = http.get("http://" + host + ":8060/query/apps", { responseType: 'xml' });
            var appsElement = appsResponse.data;
            apps = {};
            appsElement.elements.forEach(function (node) {
                var appId = node.attributes.id;
                var appName = node.text;
                apps[appName] = appId;
            });
        }

        // set the input and app list
        console.log("  building input sources...");
        var inputSources = [];

        // build list of input sources (includes static names as well as app names)
        inputSources = inputSources.concat(Object.keys(inputSourceStaticMappings));
        if(Object.keys(apps).length > 0) {
            // we get an error if there are no apps, likely because the ECP isn't working
            inputSources = inputSources.concat(Object.keys(apps));
        }
        console.log("  setting inputs and apps...");
        device.SupportedInputSources = inputSources;
        console.log("  set (" + device.SupportedInputSources.length + "): " + device.SupportedInputSources);

        // get the active app
        console.log("  getting active app...");
        rokuGetActiveApp();

        // set the media commands
        console.log("  building supported media commands...");
        var mediaCommands = [];

        // build all of our actual MediaCommands
        mediaCommands = mediaCommands.concat(Object.keys(mediaCommandMappings));
        mediaCommands = mediaCommands.concat(Object.keys(mediaCommandShiftMappings));
        mediaCommands = mediaCommands.concat(Object.keys(mediaCommandKeyMappings));

        console.log("  setting supported media commands...");
        device.SupportedMediaCommands = mediaCommands;
        console.log("  set (" + device.SupportedMediaCommands.length + "): " + device.SupportedMediaCommands);
    }
}

function onDisconnect() {
    console.log("onDisconnect called...");
}

function onPoll() {
    // refresh the active app
    rokuGetActiveApp();
}

function onSynchronizeDevices() {
    console.log("onSynchronizeDevices called...");

    var device = new Device();

    device.Id = plugin.Name;
    device.DisplayName = "Roku";
    device.Icon = "Remote";
    device.Capabilities = [ "MediaControl", "MediaInputSource" ];
    device.Attributes = [ ];

    plugin.Devices[plugin.Name] = device;

    console.log("  done syncing");
}
1 Like

I’m getting the message “no public method for the specified argument exists” when I try to synchronize. This is for the 08-04 version as well as the 04-28 version.

Is there a line number associated with the error? What does the console log show?

Wow, how have I never noticed the log at the bottom of the designer. That’s going to make my journey so much easier. Here’s the output:

onConnect called...
  Host: 192.168.0.13
onSynchronizeDevices called...
  done syncing
onDisconnect called...
onConnect called...
  Host: 192.168.0.13
  building apps list...
  setting apps...
  set (12):
No public methods with the specified arguments were found.
onDisconnect called...

The console log is really helpful when trying to debug plugins. If you have the screen space (or a second monitor), it’s really nice to make it a floating window and pull it off to the side so it’s easier to watch while interacting with your plugin in the simulator.

With respect to the specific problem you’re having, the console log was exceedingly helpful in finding where the problem was. As for why it was a problem, I’m a little less clear, because the particular code hasn’t been changed in a while and I think it used to work. Perhaps @bill has some insights on this–javascript is not my strong suit, but the issue was that there was a line in onConnect() (2 of them actually, but you weren’t getting to the second one) that was doing:

console.log(device.SupportedInputSources)

And apparently javascript does not accept the array as-is as the input parameter to the logging function and also did not automatically convert it to a string. I normally run my plugins with the plugin logging stuff I created here (since it is very useful for debugging on the device itself), and since it prefixes every line with a common string, it was always doing the array->string conversion. Still, I was pretty sure I had tested the version posted as-is as well. Sorry about that. I’ll fix the posted plugin shortly–it’s a really simple fix–and hopefully you’ll be good to go!

Thanks so much for your help on this. It would synchronize correctly the first time but would error on subsequent times so I commented out lines 252 and 269:

console.log(" set (" + device.SupportedInputSources.length + "): " + device.SupportedInputSources);

console.log(" set (" + device.SupportedMediaCommands.length + "): " + device.SupportedMediaCommands);

and it works great! That keyboard is fantastic too! I love the way you coded the shift key.

1 Like

Glad you were able to get it (and the keyboard) working! Regarding having to comment out those lines, that was going to be my next suggestion, although it really shouldn’t be necessary–you don’t need to synchronize devices more than once (or at all, I don’t believe, with the new plugin import feature).

UPDATE: this was due to a bug in Home Remote that will be fixed in v3.6.0.

1 Like

I made a couple changes to your plugin.

The block of code that sets SupportedInputSources on the device needs to be called on every connect. Not just on initial connect when building the app list. Devices are completely reset on disconnect so that’s why it needs to be updated again.

I also added the PowerOff command. My Roku Streaming Stick+ supports this command. When it is off, the network is still active so you can still make API calls. It just turns the video output off so you’ll get a black screen.

Roku-HFN-2020-09-12.plugin (9.5 KB)

2 Likes

Thanks, Bill! I incorporated your changes and updated my initial post. I also went ahead and added several other commands that some Roku devices support, just in case you or others are interested in them.

One thing I didn’t add was for input select on a device like Roku TV. Interestingly, those are handled as key-presses, but I think in Home Remote they should be part of the MediaInputSource capability. It should be relatively straightforward to add those, but it does involve some additional logic and, since I can’t test it, I held off. If anyone is interested, let me know, and we can make it work.

Here I made another change. I’ve also added PowerOn & PowerToggle commands.

@amingle sent me a message the other day showing his use of the Power/PowerToggle command on a Roku TV. Just out of curiosity, I tested it on my Roku Streaming Stick+ & it worked on there too.

I was incorrect earlier when I said it was turning the video off. It’s actually turning the TV off. I assume it’s using HDMI CEC to incorporate these power controls.

Roku-HFN-2020-09-16-B.plugin (10.4 KB)

1 Like

Thanks again, Bill! I went ahead and also added in all of the other documented commands as well as adding the input commands. The input commands work through the InputSource attribute, just like the apps do, so, for example, you can InputSource = "InputHDMI1". Internally, it checks to see whether the input you’re switching is one of the predefined ones or an app and then sends the appropriate request.

I’m not able to test the input commands because I just have a regular Roku, but I’d appreciate it if someone could let me know if they work on a Roku TV.

As I was typing this up, it occurred to me that more work may still be needed because when reading InputSource, it will only show the current app, not the current TV input. I can’t really think of an easy fix for that problem. It would probably be better to have a second capability/attribute, or maybe even a second device, like a multi-zone receiver, one for the streamer portion and one for the TV portion, each having it’s own associated input… Without someone to at least assist in testing, however, I don’t want to go down that road blind (since I don’t have a Roku TV to test against). It’s an interesting problem, though.

1 Like

I have a Roku TV, so I can take a look, hopefully / maybe this weekend. I ended up taking your original Roku plugin and modified it to add in some things about current TV input / live TV channel / etc., so I’ll see what else I did that may help round it out for TVs. I integrated a “now playing” functionality that has to do different things based on whether it is playing an app or playing OTA TV. If I understand your post right, that’s getting at the heart of what you’re talking about here.

Thanks for all your work with this.

Thanks so much for testing it out–I hate not being able to test code before I publish it, but couldn’t be helped in this case. I’d also be very interested to see what other features you worked into your modified plugin and figure out a good way to incorporate them in so we can have a good one-stop-shop Roku plugin.