plugin.Name = "MCEController"; 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", "Port" : "5150", "MAC" : "11:22:33:44:55:66", // for Wake-on-LAN "MatchModifiersOnConnect" : "TRUE", }; var VERSION = "2023-05-27-TEST"; var WOLPORT = ""; /* This is intended to be a flexible, general purpose interface to MCEC (the MCEC Controller (https://tig.github.io/mcec/)). Consequently, it provides a large number of defined MediaCommands along with some additional functionality to make using it fairly easy in many situations. It can, of course, be customized further, but the intention is to avoid the need for any customization. The initial use cases this was written for were to provide a fairly simple interface to Plex Home Theater/Plex Media Player and a full-featured keyboard. However, it should allow for interfacing with MCEC in an intuitive way for (hopefully) most situations. There are several categories of MediaCommand mappings below, each of which may be treated differently, but which are all combined to be available as MediaCommands that can be sent by Home Remote user interface elements, as usual. Here is a brief overview of each grouping: mediaCommandStandardMappings - these are standard Home Remote MediaCommands mediaCommandModifierMappings - these are commands related to modifier keys (shift, control, alt, and win). They provide several different ways to control the up/down state of the modifier keys. mediaCommandKeyMappings - these commands correspond to specific keys on the keyboard. That is, not necessarily the values of those keys, but the actual keys themselves. For example, if you want to press the "1" key, you would use the MediaCommand "Letter1". When you let MCEC handle the modifier keys as well, that can result in either a "1" (shift is not pressed) or a "!" (shift is pressed) being typed. This works very well in the case of emulating the behavior of typing on a keyboard and removes the complexity of having to configure buttons to send different codes in the user interface for shifted keys. Note that, Letter1-0 are treated differently from Number1-0. The former are treated as number keys on the keyboard and the latter are treated as keys on the number pad (and are defined in the standard HR mappings instead). mediaCommandMouseMappings - these commands send mouse button commands to MCEC. In addition to the MediaCommand mappings discussed above, there is a special MediaCommand supported. Any MediaCommand sent to the plugin that begins with "MCEC:" will be sent as-is to MCEC (with the "MCEC:" prefix removed, of course). So, for example, to send MCEC the "restart" command, you would use the MediaCommand "MCEC:restart". This plugin is written for the standard US keyboard layout and sends the virtual key ("vk_") codes that correspond to the appropriate keys in that keyboard layout. For alphanumeric keys, it will probably work fine on many other layouts. However, for non-alphanumeric characters (for example, `, ~, [, ], ;, :, ', ", etc.) other keyboards may use different virtual key codes. */ var socket = new TCPClient(); var debug = true; var LOG_MAX = 5000; // maximum size of the log in characters; set to 0 for unlimited var LOG_TRIM = 500; // when maximum size reached, trim the log plus this much more so not constantly trimming var MCEC_RAW_CMD_PREFIX = "MCEC:"; var MCEC_RETRY_ATTEMPTS = 3; // These are used to track the up/down state of modifier keys. // NOTE: while tracked, these states are not currently used, but would be needed if this plugin // was extended to support commands to send literal values rather than keys (a feature // that was considered but not (yet) implemented). var modifierStates = { "shift" : false, "ctrl" : false, "alt" : false, "lwin" : false, "rwin" : false, }; // These are standardized Home Remote MediaCommand mappings. var mediaCommandStandardMappings = { "PowerOn" : "WOL", "PowerOff" : "standby", "DirectionUp" : "vk_up", "DirectionLeft" : "vk_left", "DirectionDown" : "vk_down", "DirectionRight" : "vk_right", "Select" : "vk_return", "Enter" : "vk_return", "Back" : "vk_back", "Rewind" : "rew", "Play" : "vk_media_play_pause", "Pause" : "vk_media_play_pause", "PlayPause" : "vk_media_play_pause", "FastForward" : "fwd", "SkipBackward" : "vk_media_prev_track", "SkipForward" : "vk_media_next_track", "Stop" : "vk_media_stop", "Number1" : "vk_numpad1", "Number2" : "vk_numpad2", "Number3" : "vk_numpad3", "Number4" : "vk_numpad4", "Number5" : "vk_numpad5", "Number6" : "vk_numpad6", "Number7" : "vk_numpad7", "Number8" : "vk_numpad8", "Number9" : "vk_numpad9", "Number0" : "vk_numpad0", }; // These commands are for controlling the state of modifier keys. var mediaCommandModifierMappings = { "LetterShift" : "shift", "LetterShiftOn" : "shift", "LetterShiftOff" : "shift", "LetterControl" : "ctrl", "LetterControlOn" : "ctrl", "LetterControlOff" : "ctrl", "LetterAlt" : "alt", "LetterAltOn" : "alt", "LetterAltOff" : "alt", "LetterWin" : "lwin", "LetterWinOn" : "lwin", "LetterWinOff" : "lwin", "LetterLWin" : "lwin", "LetterLWinOn" : "lwin", "LetterLWinOff" : "lwin", "LetterRWin" : "rwin", "LetterRWinOn" : "rwin", "LetterRWinOff" : "rwin", }; // These commands correspond to specific keys. That is, not necessarily // the unshift/shifted values of those keys, but the actual keys themselves. // When you let MCEC handle the modifier keys as well, using these will have // the behavior of typing on the keyboard. This removes the complexity of // having to send different codes in the user interface for shifted keys. var mediaCommandKeyMappings = { "LetterA" : "vk_a", "LetterB" : "vk_b", "LetterC" : "vk_c", "LetterD" : "vk_d", "LetterE" : "vk_e", "LetterF" : "vk_f", "LetterG" : "vk_g", "LetterH" : "vk_h", "LetterI" : "vk_i", "LetterJ" : "vk_j", "LetterK" : "vk_k", "LetterL" : "vk_l", "LetterM" : "vk_m", "LetterN" : "vk_n", "LetterO" : "vk_o", "LetterP" : "vk_p", "LetterQ" : "vk_q", "LetterR" : "vk_r", "LetterS" : "vk_s", "LetterT" : "vk_t", "LetterU" : "vk_u", "LetterV" : "vk_v", "LetterW" : "vk_w", "LetterX" : "vk_x", "LetterY" : "vk_y", "LetterZ" : "vk_z", "Letter1" : "vk_1", "Letter2" : "vk_2", "Letter3" : "vk_3", "Letter4" : "vk_4", "Letter5" : "vk_5", "Letter6" : "vk_6", "Letter7" : "vk_7", "Letter8" : "vk_8", "Letter9" : "vk_9", "Letter0" : "vk_0", "LetterEscape" : "vk_escape", "LetterBackspace" : "vk_back", "LetterTab" : "vk_tab", "LetterSpace" : "vk_space", "LetterBackQuote" : "vk_oem_3", "LetterHyphen" : "vk_oem_minus", "LetterEqual" : "vk_oem_plus", // despite the name, this really is the equal key "LetterLeftSquareBracket" : "vk_oem_4", "LetterRightSquareBracket" : "vk_oem_6", "LetterBackslash" : "vk_oem_5", "LetterSemicolon" : "vk_oem_1", "LetterSingleQuote" : "vk_oem_7", "LetterComma" : "vk_oem_comma", "LetterPeriod" : "vk_oem_period", "LetterSlash" : "vk_oem_2", "LetterInsert" : "vk_insert", "LetterDelete" : "vk_delete", "LetterHome" : "vk_home", "LetterEnd" : "vk_end", "LetterPageUp" : "vk_prior", "LetterPageDown" : "vk_next", "LetterF1" : "vk_f1", "LetterF2" : "vk_f2", "LetterF3" : "vk_f3", "LetterF4" : "vk_f4", "LetterF5" : "vk_f5", "LetterF6" : "vk_f6", "LetterF7" : "vk_f7", "LetterF8" : "vk_f8", "LetterF9" : "vk_f9", "LetterF10" : "vk_f10", "LetterF11" : "vk_f11", "LetterF12" : "vk_f12", }; // These commands are for mouse buttons. var mediaCommandMouseMappings = { "MouseLeftClick" : "mouse:lbc", "MouseLeftDoubleClick" : "mouse:lbdc", "MouseLeftDown" : "mouse:lbd", "MouseLeftUp" : "mouse:lbu", "MouseRightClick" : "mouse:rbc", "MouseRightDoubleClick" : "mouse:rbdc", "MouseRightDown" : "mouse:rbd", "MouseRightUp" : "mouse:rbu", "MouseMiddleClick" : "mouse:mbc", "MouseMiddleDoubleClick": "mouse:mbdc", "MouseMiddleDown" : "mouse:mbd", "MouseMiddleUp" : "mouse:mbu", }; function debugLog(s) { // Logs a string to both the console and the device Log attribute // because the console is not available in the app. It can also // trim the device Log to keep it from growing too large if // LOG_MAX is not 0. if(debug) { var device = plugin.Devices[plugin.Name]; // is this the first time through? if(device && !device.Log) { // log is empty, so start it off with version info var verstr = plugin.Name + ": version " + VERSION; console.log(verstr); device.Log = verstr + "\n"; } var logstr = plugin.Name + ": " + s; // send it to the console console.log(logstr); // send it to the device log if(device && device.Log) { device.Log += logstr + "\n"; // trim the log if necessary if(LOG_MAX && (device.Log.length > LOG_MAX)) { device.Log = device.Log.slice(-(LOG_MAX + LOG_TRIM)); } } } } // this function can optionally re-throw a connection error or suppress it // to allow for retrying at a higher level instead function mcecConnect(suppressErrorOnConnect) { if(!socket.isConnected) { var host = plugin.Settings["Host"]; var port = plugin.Settings["Port"]; debugLog(" mcecConnect() attempting connection..."); debugLog(" Host: " + host); debugLog(" Port: " + port); try { socket.connect(host, port); } catch(err) { // unable to connect, that's not good... debugLog("ERROR: unable to connect to MCEC: " + err.message); if(suppressErrorOnConnect) { return false; } else { throw err; } } // connected debugLog(" mcecConnect() connected..."); // see if we need to match modifiers if((plugin.Settings["MatchModifiersOnConnect"].toUpperCase() == "TRUE") || (plugin.Settings["MatchModifiersOnConnect"].toUpperCase() == "YES") || (plugin.Settings["MatchModifiersOnConnect"].toUpperCase() == "Y") ) { debugLog(" mcecConnect() matching modifiers..."); var modifiers = Object.keys(modifierStates); for(var m = 0; m < modifiers.length; m++) { var modKey = modifiers[m] var modVal = modifierStates[modKey] var modCmd = (modVal ? "shiftdown:" : "shiftup:") + modKey; debugLog(" setting '" + modKey + "' modifier to: " + modVal); // send the command mcecSend(modCmd); } } } return true; } function mcecDisconnect() { if(socket.isConnected) { try { socket.close(); } catch(err) { // we're closing down anyway, so it doesn't matter if the close() // failed--just fail silently } } } // wrapper that tries to make sure we're connected before sending // returns true if the send worked, false otherwise, to make // having resend logic simple function mcecSend(c) { if(!socket.isConnected) { // not connected for some reason, try to connect first, but don't // throw an error because we may retry the send mcecConnect(true); } try { // send the command socket.send(c + "\r\n"); } catch(err) { // send didn't work for some reason (probably connection closed by other end and we // just found out about it) debugLog("ERROR: unable to send to MCEC: " + err.message); // let the caller know it didn't work return false; } // seems to have worked okay if we got this far return true; } function mcecGetCommands(mc) { // NOTE: the reason we use an array is because, in the future, it may be // the case that a single MediaCommand requires multiple MCEC commands // to be sent, such as in the case of literal value MediaCommands to // control modifier key states, for example. var mceCmds = [ ]; if((mc.slice(0, MCEC_RAW_CMD_PREFIX.length)) == MCEC_RAW_CMD_PREFIX) { debugLog(" MCEC raw command identified: " + mc); // extract the MCEC raw command and use it as-specified var mceRawCmd = mc.slice(MCEC_RAW_CMD_PREFIX.length); if(mceRawCmd) { mceCmds.push(mceRawCmd); } else { debugLog("ERROR: '" + MCEC_RAW_CMD_PREFIX + "' must be followed by a command to send"); } } else if(mediaCommandStandardMappings.hasOwnProperty(mc)) { debugLog(" standard HR command identified: " + mc); // standard commands, aside from PowerOn, have no special processing needed if(mc == "PowerOn") { var mac = plugin.Settings["MAC"]; var wolOpts = {}; wolOpts["address"] = plugin.Settings["Host"]; if(WOLPORT != "") { wolOpts["port"] = parseInt(WOLPORT); } debugLog(" sending WOL to: " + mac + " with " + wolOpts); WOL.wake(mac, wolOpts); } else { mceCmds.push(mediaCommandStandardMappings[mc]); } } else if(mediaCommandModifierMappings.hasOwnProperty(mc)) { debugLog(" modifier command identified: " + mc); // handle modifier commands by updating our internal modifier state for // the relevant modifier key and then building command to send to MCEC var modKey = mediaCommandModifierMappings[mc]; if(modifierStates.hasOwnProperty(modKey)) { // because apparently we don't have endsWith()... if(mc.slice(-2) == "On") { modifierStates[modKey] = true; } else if(mc.slice(-3) == "Off") { modifierStates[modKey] = false; } else { // toggle relevant shift state modifierStates[modKey] = !modifierStates[modKey]; } debugLog(" modifier state for " + modKey + " is now " + modifierStates[modKey]); // set up command to send to MCEC mceCmds.push(((modifierStates[modKey]) ? "shiftdown:" : "shiftup:") + modKey); } else { debugLog("ERROR: unknown modifier key: " + modKey); } } else if(mediaCommandKeyMappings.hasOwnProperty(mc)) { debugLog(" key command identified: " + mc); // keys do not require special processing mceCmds.push(mediaCommandKeyMappings[mc]); } else if(mediaCommandMouseMappings.hasOwnProperty(mc)) { debugLog(" mouse command identified: " + mc); // mouse commands do not require special processing mceCmds.push(mediaCommandMouseMappings[mc]); } else { // unknown command debugLog("ERROR: unsupported command: " + mc); } return mceCmds; } function onChangeRequest(device, attribute, value) { debugLog("onChangeRequest called..."); switch(attribute) { case "MediaCommand": var cmds = [ ]; // get the appropriate MCEC command(s) for the specified // Home Remote MediaCommand cmds = mcecGetCommands(value); // now we're ready to send the command(s) if(cmds.length) { for(var c = 0; c < cmds.length; c++) { var sendSuccess = false; debugLog(" sending MCEC command: " + cmds[c]); // try to send the command; if it doesn't work, retry for(var attempt = 0; attempt < MCEC_RETRY_ATTEMPTS; attempt++) { if(mcecSend(cmds[c])) { // that send worked, break out of the retry loop debugLog(" command sent successfully..."); sendSuccess = true; break; } else { // didn't send successfully, so wait briefly and try again debugLog(" send unsuccessful, retrying shortly..."); sleep(500); } } // make sure we eventually sent the command and if not, throw an error if(!sendSuccess) { debugLog("ERROR: unable to successfully send command!"); throw new Error(plugin.Name + ": unable to send command to MCEC!"); } // done with that command, move on to the next one... } } // all done break; default: debugLog("ERROR: unsupported command type: " + attribute); break; } } function onConnect() { debugLog("onConnect called..."); var device = plugin.Devices[plugin.Name]; if(device) { debugLog(" building supported media commands..."); var mediaCommandMappings = []; // build all of our actual MediaCommands mediaCommandMappings = mediaCommandMappings.concat(Object.keys(mediaCommandStandardMappings)); mediaCommandMappings = mediaCommandMappings.concat(Object.keys(mediaCommandModifierMappings)); mediaCommandMappings = mediaCommandMappings.concat(Object.keys(mediaCommandKeyMappings)); mediaCommandMappings = mediaCommandMappings.concat(Object.keys(mediaCommandMouseMappings)); // add the special MCEC raw command prefix mediaCommandMappings[MCEC_RAW_CMD_PREFIX]; debugLog(" setting supported media commands..."); device.SupportedMediaCommands = mediaCommandMappings; debugLog(" set (" + device.SupportedMediaCommands.length + "): " + device.SupportedMediaCommands); } // connect to the MCEC server debugLog(" connecting..."); mcecConnect(); debugLog(" connected"); } function onDisconnect() { debugLog("onDisconnect called..."); mcecDisconnect(); debugLog(" disconnected"); } function onPoll() { debugLog("onPoll called..."); } function onSynchronizeDevices() { debugLog("onSynchronizeDevices called..."); var device = new Device(); device.Name = plugin.Name; // override default naming conversion so it looks nicer device.Id = plugin.Name; device.DisplayName = "MCE Controller"; device.Icon = "Remote"; device.Capabilities = [ "MediaControl" ]; device.Attributes = [ "Log" ]; plugin.Devices[plugin.Name] = device; debugLog(" done syncing"); }