Roku (HFN) Plugin

This is a pluging for Roku that is based on Bill’s Roku plugin originally posted April 5, 2020 in the old forum. This version plugin adds keyboard support in addition to adding a little more debugging stuff and tweaking a few stylistic things to satisfy my OCD :slight_smile: 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-04-28
Roku-HFN-2020-04-28.plugin (9.0 KB)

plugin.Name = "Roku";
plugin.OnChangeRequest = onChangeRequest;
plugin.OnConnect = onConnect;
plugin.OnDisconnect = onDisconnect;
plugin.OnPoll = onPoll;
plugin.OnSynchronizeDevices = onSynchronizeDevices;
plugin.PollingInterval = -1;
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-04-28";

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

    It's been extended to accept keyboard commands per the Roku documentation and uses
    some concepts from the MCE Controller Plugin I wrote.  In addition, there are a few
    code cleanups/stylistic changes to match some other plugins I've written, but the
    core of the work is still Bill's.
*/

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",

    "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" /* ? */ ],
};


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":
            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 httpConfig = { responseType: 'xml' };
            var appsResponse = http.get("http://" + host + ":8060/query/apps", httpConfig);
            var appsElement = appsResponse.data;
            apps = {};
            appsElement.elements.forEach(function (node) {
                var appId = node.attributes.id;
                var appName = node.text;
                apps[appName] = appId;
            });

            console.log("  setting apps...");
            if(Object.keys(apps).length > 0) {
                // we get an error if there are no apps, likely because the ECP isn't working
                device.SupportedInputSources = Object.keys(apps);
            }
            console.log("  set (" + device.SupportedInputSources.length + "):");
            console.log(device.SupportedInputSources);
        }

        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 + "):");
        console.log(device.SupportedMediaCommands);
    }
}

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

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

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