Flic Home

    Community

    • Login
    • Search
    • Popular
    • Users

    IRTCP v1.0.0 (Hub server for using IR module via HTTP)

    Flic Hub SDK
    6
    16
    5904
    Loading More Posts
    • Oldest to Newest
    • Newest to Oldest
    • Most Votes
    Reply
    • Reply as topic
    Log in to reply
    This topic has been deleted. Only users with topic management privileges can see it.
    • oskaremilsson
      oskaremilsson last edited by oskaremilsson

      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.
      Using net 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 helpful 🙂

      The code is what could be called a POC, so code feedback and/or changes are totally welcome.

      How does it work?

      1. Record a signal GET {ip}:1338?record={name_of_signal}
      2. Plays a signal GET {ip}:1338?cmd={name_of_signal}
      3. Cancel recording GET {ip}:1338?cancelRecord=true

      Example;

      1. call GET 192.168.0.100:1338?record=volume_up
      2. Press Volume Up on remote towards IR module
      3. returns 200 OK Signal volume_up stored!
      4. call GET 192.168.0.100:1338?cmd=volume_up
      5. returns 200 OK Signal sent!
      6. 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, "0.0.0.0", 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;
      
      1 Reply Last reply Reply Quote 2
      • mullz
        mullz @Emil last edited by

        @Emil This is helpful info for investigation, but I still can't figure out why the console output appears to indicate overwriting multiple commands every time a new one is recorded:

        server connected
        GET /?record=test HTTP/1.1

        Host: flichub.local:1338

        Accept: /

        Accept-Language: en-US;q=1, ru-RU;q=0.9, es-US;q=0.8

        Connection: keep-alive

        Accept-Encoding: gzip, deflate

        User-Agent: Flic/7.0.5 (iPhone; iOS 26.1; Scale/3.00)

        recording signal...
        Signal testUp stored!
        Signal testDwn stored!
        Signal test stored!
        Signal test stored!
        server disconnected
        server connected
        GET /?cmd=test HTTP/1.1

        Host: flichub.local:1338

        Accept: /

        Accept-Language: en-US;q=1, ru-RU;q=0.9, es-US;q=0.8

        Connection: keep-alive

        Accept-Encoding: gzip, deflate

        User-Agent: Flic/7.0.5 (iPhone; iOS 26.1; Scale/3.00)

        server disconnected

        Unless I am reading it wrong, recording a new signal saves it, but also overwrites all previous recordings with the new IR signal, as well.

        (I sent these commands from the iOS flic app, using the Internet Request feature, from one of the widgets, if that is helpful info)

        1 Reply Last reply Reply Quote 0
        • Emil
          Emil FlicTeam @mullz last edited by Emil

          @mullz I couldn't reproduce the issue. Seems to work as intended for me with oskaremilsson's code.
          One thing I noticed is that the conversion to string might not always work correctly the way the encoding is done. The string that is to be saved needs to be stored in proper UTF-16 format; now you could theoretically accidentally create invalid surrogate pairs.

          mullz 1 Reply Last reply Reply Quote 0
          • mullz
            mullz last edited by

            Hello,

            I've been trying to get this script up and running on my own Flic Hub LR (firmware v4.6.0), but I seem to be running into trouble with the datastore module; nothing appears to stay saved, or else entering one IR code overwrites ALL IR codes with with string from the most recently recorded one. When I tried to investigate what was being written to the datastore, I noticed the datastore.db file appears corrupt in some way (dB Browser for SQLite says it's unreadable/malformed). This happens in both the original "IRTCP" code @oskaremilsson posted, as well as the "ir-server" version posted by @jitmo.

            Am I missing something silly, or is there a change in the way the datastore puts/gets work in the latest firmware?

            Emil 1 Reply Last reply Reply Quote 0
            • Emil
              Emil FlicTeam @oskaremilsson last edited by

              The next firmware update will allow for the previous call signature as well.

              1 Reply Last reply Reply Quote 0
              • oskaremilsson
                oskaremilsson @johan 0 last edited by

                @johan-0 This is the best thing I have ever encountered in my life of a developer. I just googled the issue I got in console and arrived at my own thread where the answer was already solved. hahah

                Emil 1 Reply Last reply Reply Quote 0
                • johan 0
                  johan 0 @Emil last edited by

                  @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 😃

                  oskaremilsson 1 Reply Last reply Reply Quote 0
                  • Emil
                    Emil FlicTeam @johan 0 last edited by

                    @johan-0 could you try to change the listen call to

                    server.listen(1338, "0.0.0.0", function(...
                    

                    instead?

                    johan 0 1 Reply Last reply Reply Quote 0
                    • johan 0
                      johan 0 last edited by

                      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)
                      
                      Emil 1 Reply Last reply Reply Quote 1
                      • andreas.lorentsen
                        andreas.lorentsen last edited by andreas.lorentsen

                        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 🙂

                        ❤️👍🏼

                        1 Reply Last reply Reply Quote 0
                        • jitmo
                          jitmo @oskaremilsson last edited by

                          Hey @oskaremilsson, works for me 🙂

                          1 Reply Last reply Reply Quote 0
                          • oskaremilsson
                            oskaremilsson @jitmo last edited by

                            @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?

                            jitmo 1 Reply Last reply Reply Quote 0
                            • jitmo
                              jitmo last edited by jitmo

                              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 others
                              • GET /getIRarray/<IR_NAME> to share your IR arrays with others
                              • GET /recordIR/<IR_NAME> to record an IR array, with timeout
                              • GET /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;
                              
                              oskaremilsson 1 Reply Last reply Reply Quote 1
                              • oskaremilsson
                                oskaremilsson @Emil last edited by

                                @Emil Thanks, and thank you for quick responses on questions

                                1 Reply Last reply Reply Quote 0
                                • Emil
                                  Emil FlicTeam last edited by

                                  Nice!
                                  This is great work, like to see more of this.

                                  oskaremilsson 1 Reply Last reply Reply Quote 0
                                  • oskaremilsson
                                    oskaremilsson last edited by

                                    I forgot to add how I'm calling this from my homecreen of my phone.
                                    I'm using HTTP Shortcuts-app widget.

                                    1 Reply Last reply Reply Quote 0
                                    • First post
                                      Last post