IRTCP v1.0.0 (Hub server for using IR module via HTTP)
-
What is it?
Hub TCP server for using the IR module via HTTP.
Both to record of new signals with a given name and to play them back.
Usingnet
since SDK doesn't have the nodejs HTTP-module available.Why?
I really wanted to be able to call my IR module from my phone homescreen or Google Home (via IFTTT). I got it working so I wanted to share it if anyone else find it helpfulThe code is what could be called a POC, so code feedback and/or changes are totally welcome.
How does it work?
- Record a signal
GET {ip}:1338?record={name_of_signal}
- Plays a signal
GET {ip}:1338?cmd={name_of_signal}
- Cancel recording
GET {ip}:1338?cancelRecord=true
Example;
- call
GET 192.168.0.100:1338?record=volume_up
- Press Volume Up on remote towards IR module
- returns
200 OK Signal volume_up stored!
- call
GET 192.168.0.100:1338?cmd=volume_up
- returns
200 OK Signal sent!
- Volume goes up
The code
module.json
{ "name": "IRTCP", "version": "1.0.0" }
main.js
// main.js const net = require("net"); const ir = require("ir"); const datastore = require("datastore"); const utils = require("./utils"); const respondToClient = function(c, statusCode, message) { c.write( "HTTP/1.1 " + statusCode + "\r\n" + "\r\n" ); c.write(message); c.end(); } const handleCmd = function(c, cmd) { datastore.get(cmd, function(err, strBuffer) { if (!err && strBuffer) { var signal = new Uint32Array(utils.str2ab(strBuffer)); if (!ArrayBuffer.isView(signal)) { return respondToClient(c, "422 Unprocessable Entity", "Unknown signal"); } ir.play(signal, function(err) { if (!err) { return respondToClient(c, "200 OK", "Signal sent"); } return respondToClient(c, "500 Internal Server Error", "Couldn't send signal"); }); } else { return respondToClient(c, "422 Unprocessable Entity", "Unknown cmd"); } }); } const handleRecord = function(c, name) { /* TODO: add a timeout for listening for complete */ ir.record(); console.log("recording signal..."); ir.on("recordComplete", function(signal) { datastore.put(name, utils.ab2str(signal.buffer), function(err) { if (!err) { console.log("Signal " + name + " stored!"); return respondToClient(c, "200 OK", "Signal " + name + " stored!"); } else { return respondToClient(c, "500 Internal Server Error", "Couldn't store signal"); } }); }); } var server = net.createServer(function(c) { console.log('server connected'); c.on('end', function() { console.log('server disconnected'); }); c.on('data', function(data) { console.log(data); var match = data.toString().match(/GET \/\?.[^ ]*/); if (!match) { return respondToClient(c, "403 Forbidden", "Forbidden method"); } var params = utils.parseParams(match[0].split("GET /?")[1]); if (params.cmd) { return handleCmd(c, params.cmd); } else if (params.record && params.record.length > 0) { return handleRecord(c, params.record); } else if (params.cancelRecord) { ir.cancelRecord(); console.log("recording canceled!"); return respondToClient(c, "200 OK", "Recording canceled!"); } else { return respondToClient(c, "403 Forbidden", "Forbidden params"); } }); }); server.listen(1338, function() { console.log('server bound', server.address().port); });
utils.js
const ab2str = function(buf) { return String.fromCharCode.apply(null, new Uint16Array(buf)); } const str2ab = function(str) { var buf = new ArrayBuffer(str.length*2); var bufView = new Uint16Array(buf); for (var i=0, strLen=str.length; i < strLen; i++) { bufView[i] = str.charCodeAt(i); } return buf; } const parseParams = function (s) { var obj = {}; var arr = s.split("&"); var temp; for (i = 0; i < arr.length; i++) { temp = arr[i].split("="); obj[temp[0]] = temp[1]; } return obj; }; exports.ab2str = ab2str; exports.str2ab = str2ab; exports.parseParams = parseParams;
- Record a signal
-
@Emil It worked, thanks so much! I didn't get any email notification about a response so I just figured no one would answer, but now I got around to looking at it again
-
@johan-0 could you try to change the
listen
call toserver.listen(1338, "0.0.0.0", function(...
instead?
-
Hej @oskaremilsson and others using this, thanks for the code! I've been using this successfully for years, and uploaded it with some modifications to GitHub. Now it just stopped working and I don't understand why, but I suspect an API change? @emil maybe you can answer this? My flic hub is on version 4.3.4 and I'm getting this error
RangeError: host does not a contain a valid IPv4 address: undefined at listen (core.js:1677) at <anonymous> (root/IR Server/tcpServer.js:123) at require (core.js:2902) at <anonymous> (root/IR Server/main.js:3) at require (core.js:2902) at requireMain (core.js:2910) at handlePacket (core.js:2944) at onPipeData (core.js:3015)
-
This is awesome!
In lack of better Javascript skills, I actually configured a Flic 2 button (through the "front door" main app interface ), just to be able to trigger the record action without a phone. Even though it worked, it felt kind of "offensive" to the code (like using a MacBook Pro as a bookend ). I guess I'll hack around with the example code just to get everything in one place️
-
Hey @oskaremilsson, works for me
-
@jitmo simply amazing!
If I were to put the original code on GitHub, would you be up to make these changes there so we get a nice history and stuff? -
Hey @oskaremilsson and @Emil, great work on getting this together. I have built on top of this with the following:
- timeout when recording an IR signal
- optional logging of TCP requests/responses
- ignore favicon.ico requests when using a browser
- the server now has 4 methods using a semantic url path
The four server methods are:
GET /putIRarray/<IR_NAME>:<IR_ARRAY>
to store IR arrays crowd-sourced from othersGET /getIRarray/<IR_NAME>
to share your IR arrays with othersGET /recordIR/<IR_NAME>
to record an IR array, with timeoutGET /playIR/<IR_SEQUENCE>
to play IR sequences
<IR_NAME>
(string) is the name you choose for your IR code(s)<IR_ARRAY>
(string) is, without spaces, in the form from the Flic Hub SDK docs, eg:[<CARRIER_FREQ>,<ON_us>,<OFF_us>,<ON_us>,...]
<IR_SEQUENCE>
(string) is a comma-separated list of<IR_NAME>
, eg:/playIR/tvOn or playIR/tvOn,lightsOff
To stagger when the IR codes start use
<IR_NAME>:<SECONDS_DELAY>
(0.1 second precision), eg:playIR/tvOn:2.0,lightsOff
Here's the code:
// main.js const tcpServer = require('./tcpServer');
// tcpServer.js // Based on work by Oskar Emilsson https://community.flic.io/topic/18043/irtcp-v1-0-0-hub-server-for-using-ir-module-via-http const net = require('net'); const irUtils = require('./irUtils'); // Set true to log TCP (HTTP) requests/responses const logRequestResponse = false; const respondToClient = function(c, statusCode, message, log) { var content = 'HTTP/1.1 '+statusCode+'\r\n'+'\r\n'+message; if(typeof log === 'undefined') log = logRequestResponse; if(log) { console.log('\n# HTTP RESPONSE'); console.log(content); console.log('# END HTTP RESPONSE\n'); } c.write(content); c.end(); } var server = net.createServer(function(c) { console.log('Server connected'); c.on('end', function() { console.log('Server disconnected\n'); }); c.on('data', function(data) { // Convert TCP content byte array to string var content = data.toString(); // Ignore favicon requests from a browser if(content.indexOf('GET /favicon.ico') !== -1) { console.log('# ignoring favicon.ico request'); return respondToClient(c, '200 OK', '', false); } if(logRequestResponse) { console.log('\n# HTTP REQUEST'); console.log(content); console.log('# END HTTP REQUEST\n'); } // The first line of the raw TCP will look something like this "GET playIR/tvOn:2.0,lightsOff HTTP/1.1" // Check for URL paths /recordIR/<IR_NAME>, /playIR/<IR_SEQUENCE>, /putIRarray/<IR_NAME>:<IR_ARRAY> or /getIRarray/<IR_NAME> // <IR_ARRAY> is, without spaces, in the form from the docs [<CARRIER_FREQ>,<ON_us>,<OFF_us>,<ON_us>,...] // <IR_SEQUENCE> is a comma-separated list of <IR_NAME>, eg: playIR/tvOn or playIR/tvOn,lightsOff // To stagger when the IR codes start use <IR_NAME>:<SECONDS_DELAY> (0.1 second precision), eg: playIR/tvOn:2.0,lightsOff // From the Hub SDK documentation "If another play is started before a previous one has completed, it gets enqueued and starts as soon as the previous completes (max 100 enqueued signals)" var recordIRmatch = content.match(/GET \/recordIR\/(.[^ ]*) HTTP/); var playIRmatch = content.match(/GET \/playIR\/(.[^ ]*) HTTP/); var putIRarraymatch = content.match(/GET \/putIRarray\/(.[^ ]*) HTTP/); var getIRarraymatch = content.match(/GET \/getIRarray\/(.[^ ]*) HTTP/); if(recordIRmatch && recordIRmatch[1]) { // Start recording an IR signal irUtils.record(c, recordIRmatch[1]); } else if(playIRmatch && playIRmatch[1]) { // Play an IR signal or IR signal sequence var items = playIRmatch[1].split(','); irUtils.play(c, items); } else if(putIRarraymatch && putIRarraymatch[1]) { // Store an IR signal var splitPath = putIRarraymatch[1].split(':'); if(splitPath.length == 2) { var irArray = JSON.parse(splitPath[1]); if(Array.isArray(irArray) && irArray.length % 2 === 0) { irUtils.put(c, splitPath[0], splitPath[1]); } else { respondToClient(c, '400 Bad Request', 'Use the form /putIRarray/<IR_NAME>:<IR_ARRAY>\r\n\r\n<IR_ARRAY> is, without spaces, in the form from the Flic Hub SDK docs [<CARRIER_FREQ>,<ON_us>,<OFF_us>,<ON_us>,...] and must have an even number of items (finishing with an <ON_us> item)'); } } else { respondToClient(c, '400 Bad Request', 'Use the form /putIRarray/<IR_NAME>:<IR_ARRAY>\r\n\r\n<IR_ARRAY> is, without spaces, in the form from the Flic Hub SDK docs [<CARRIER_FREQ>,<ON_us>,<OFF_us>,<ON_us>,...] and must have an even number of items (finishing with an <ON_us> item)'); } } else if(getIRarraymatch && getIRarraymatch[1]) { // Retrieve an IR signal irUtils.get(c, getIRarraymatch[1]); } else { respondToClient(c, '400 Bad Request', 'Valid url paths are recordIR/<IR_NAME> and playIR/<IR_SEQUENCE> \r\n\r\nWhere <IR_SEQUENCE> is a comma-separated list of <IR_NAME>, eg: playIR/tvOn or playIR/tvOn,lightsOff \r\n\r\nTo stagger when the IR codes start use <IR_NAME>:<SECONDS_DELAY> (0.1 second precision), eg: playIR/tvOn:2.0,lightsOff'); } }); // on.data }); // net.createServer server.listen(1338, function() { console.log('Server bound', server.address().port); }); exports.respondToClient = respondToClient;
// irUtils.js const ir = require('ir'); const datastore = require('datastore'); const server = require('./tcpServer'); // Set true to respond before playing IR signals (and after checking all signals have been recorded) // You may want a faster response for the system requesting the IR signal(s) playback, although you will not know if ir.play() fails const respondBeforePlaying = false; const _irSignal2str = function(signal) { // Instead of using signal.buffer use the JS object (undocumented, this might not always be available) // The object keys are "0", "1", etc and the values are the integers that need to be in the array // Convert the JS object to an array of integers var items = []; for(var i=0; i<Object.keys(signal).length;i++) { items.push(signal[i]); } return JSON.stringify(items); } const _str2irSignal = function(str) { return new Uint32Array(JSON.parse(str)); } const put = function(c, name, data) { console.log('@ irUtils.put',name,data); datastore.put(name, data, function(err) { console.log('@ datastore.put callback'); if (!err) { console.log('IR signal '+name+' stored'); server.respondToClient(c, '200 OK', 'IR signal '+name+' stored'); } else { console.error('# error: ', error); server.respondToClient(c, '500 Internal Server Error', 'Could not store IR signal'); } }); // datastore.put } const get = function(c, name) { console.log('@ irUtils.get '+name); datastore.get(name, function(err, str) { console.log('@ datastore.get callback'); if(!err && typeof str === 'string' && str.length > 0) { server.respondToClient(c, '200 OK', str); } else { server.respondToClient(c, '404 Not Found', 'Could not find IR signal '+name); } }); // datastore.get } const record = function(c, name) { console.log('@ irUtils.record '+name); // Start recording ir.record(); // Set up a timeout timer for 5 seconds var timeoutTimer = setTimeout(function(){ ir.cancelRecord(); console.log('Recording IR signal '+name+' TIMEOUT'); clearTimeout(timeoutTimer); server.respondToClient(c, '408 Request Timeout', 'Recording IR signal '+name+' TIMEOUT'); return; },5000); // Wait for recordComplete event ir.on('recordComplete', function(signal) { console.log('@ ir.on.recordComplete'); // Stop the timeout timer clearTimeout(timeoutTimer); // Convert the signal to a string var data = _irSignal2str(signal); console.log(data); // Store the data put(c, name, data); }); // ir.on.recordComplete } const play = function(c, items) { console.log('@ irUtils.play '+items); // Check all the IR codes exist const retrievalMs = 20; var index = 0; var irCodes = {}; var errors = ''; // datastore is async, so give each item time to be retrieved var fetchingTimer = setInterval(function(){ var item = items[index].split(':')[0]; if(typeof irCodes[item] !== 'undefined') { console.log('# '+item+' already retrieved'); if(++index === items.length) clearTimeout(fetchingTimer); } else { console.log('# getting '+item+' from datastore') datastore.get(item, function(err, str) { console.log('@ datastore.get callback'); if(!err && typeof str === 'string' && str.length > 0) { irCodes[item] = str; } else { console.error('Cannot find IR code '+item+' in datastore.'); errors += 'Cannot find IR code '+item+' in datastore. '; } if(++index === items.length) clearTimeout(fetchingTimer); }); // datastore.get } },retrievalMs); // setInterval // Wait for datastore to finish setTimeout(function(){ if(errors !== '') { server.respondToClient(c, '400 Bad Request', errors); return; } console.log(JSON.stringify(irCodes,null,2)); if(respondBeforePlaying) server.respondToClient(c, '200 OK', 'Sending IR signal(s)'); // Set up a timer to process the queue and pauses var pausingTenths = 0; var sendingTimer = setInterval(function(){ if(pausingTenths > 0) { // Keep pausing pausingTenths--; } else { if(items.length > 0) { var itemSplit = items.shift().split(':'); // Play the IR code console.log('# Sending IR code '+itemSplit[0]); var signal = _str2irSignal(irCodes[itemSplit[0]]); ir.play(signal, function(err) { if(err) { clearTimeout(sendingTimer); if(!respondBeforePlaying) server.respondToClient(c, '500 Internal Server Error', 'Could not send IR signal '+itemSplit[0]); return; } }); // Add a pause if requested if(itemSplit[1] && typeof parseFloat(itemSplit[1]) === 'number') { var pause = parseFloat(itemSplit[1]); console.log('# Adding '+pause+' seconds pause'); pausingTenths = parseInt(pause*10); } } else { // Finish up console.log('# Finished IR send'); clearTimeout(sendingTimer); if(!respondBeforePlaying) server.respondToClient(c, '200 OK', 'Sent IR signal(s)'); } } },100); // setInterval },retrievalMs*(items.length+1)); // setTimeout } exports.put = put; exports.get = get; exports.record = record; exports.play = play;
-
@Emil Thanks, and thank you for quick responses on questions
-
Nice!
This is great work, like to see more of this. -
I forgot to add how I'm calling this from my homecreen of my phone.
I'm usingHTTP Shortcuts
-app widget.