viernes, 23 de septiembre de 2016

Raspberry Pi Universal Controller - Node API (Part 4)

Intro

This is a series of posts on how to make an universal remote controller out of a Raspberry Pi and use it with a node API.
  1. Setting up LIRC
  2. Setting up IR Receiver and storing the codes
  3. Setting up IR Leds and using LIRC
  4. Create the Node API

Creating the Node API

So, I have the Raspberry Pi configured with LIRC and it's able to send the right signals to all of our devices using the IR leds attached. Now, I need a way to communicate the Pi which command I want to perform. For this, I'll create a simple Node API using express.

The code

You can just avoid all the explanation and get the code here: https://github.com/ovsleep/remote or you can stay for a (kinda) short explanation of it.

One-to-One Key Mapping Approach

At first, I though about a one-to-one mapping from the API to the remotes keys. So the urls of the API would be something like this:

http://mylocalapi:9090/{device}/{key}

So press the power on button on the TV would look like this:
http://mylocalapi:9090/tv/power_on

This have some disadvantages: 
  1. Whoever is calling this API has to know exactly the devices names and the key names
  2. For more complex actions where I require more than a key press, like watch cable (need to turn on tv, turn on receiver, turn on cable box) I would need multiple calls to the API.

Command Approach

So I came up with the command approach. Basically the API would have a single URL and it will receive a JSON with the command to execute and the data needed. So the JSON would have this structure
{
  command: string
  data: object
}

For example, if I want to watch the cable box, the JSON would be:
{
 command: 'watch'
 data: { device: 'cable' }
}

Also, I want to make this API very extensible, so I want to make it very easy to add new commands.

Having this in mind I create a command as a Node module, this is an extract of the Off command:

exports.execute = function (data) {
    //This function will be executed when the command is called
    var cmdExec = new CommandExecutor();
    
    //based on the device we want to turn off, we push device function to the commandExecutor
    switch (data.device) {
        case 'tv':
            cmdExec.addCommand(denon.off);
            cmdExec.addCommand(tv.off);
            cmdExec.addCommand(cable.off);
            break;
        case 'ac':
            cmdExec.addCommand(ac.off);
            break;
        case 'all':
            cmdExec.addCommand(denon.off);
            cmdExec.addCommand(tv.off);
            cmdExec.addCommand(cable.off);
            cmdExec.addCommand(ac.off);
            break;
    }

    //run the chained commands
    return cmdExec.execute();
}

//this will be the name of the command
exports.cmdName = 'off';

Loading the commands

Adding a command should be simple: just make a module like the one before, throw it into the Command directory and that's it. 
To do this I used require-dir module, this allow to load all the modules in a directory without specifying the name of each module
var requireDir = require('require-dir');
var commandsControllers = requireDir('./commands');
//register commands
var commands = {};
for (commandCtrl in commandsControllers) {
    cmd = commandsControllers[commandCtrl];
    if (commands[cmd.cmdName]) throw 'duplicated command: ' + cmd.cmdName;
    commands[cmd.cmdName] = cmd.execute;
}

This will go through the commands directory and register the cmdName of each module in the commands object.

So when a new request come, all I need to do is to find the command and execute it with the data provided like this:

var router = express.Router();
router.route('/remote')
    .post(function (req, res) {
        var action = req.body;
        if (commands[action.command]) {
            commands[action.command](action.data)
                .then((result) => { res.json({ message: 'OK!' }); })
                .catch((err) => { console.log(err); res.json({ message: 'FAIL!' }); });
        }
        else {
            res.json({ message: 'No Command' });
        }
    });

That's everything that the server need to do to process a command.

The Devices

These are modules representing every device that I want to control (AC/Cable Box/Receiver/TV). They are just a set of functions to execute simple operations over the device, like: turn on/off, change channel, set temperature.

Every device function returns a Promise and it uses the irsend module (an adaptation of this) or the commandExecutor module. 

The irsend module just execute the irsend command using child_process module.

The commandExecutor is just a module to easily chain the promises execute. 

So, for example, the cable device looks like this:

var irsend = require('./irsend')
const CommandExecutor = require('../commandExecutor');
const channels = {
    DISCOVERY: '1732',
    HISTORY: '1742',
    DEPORTES: '1621',
    FX: '1217',
    FOX: '1204',
    FUTBOL: '1184'
}
exports.on = function () {
    return irsend.send_once('directv', 'key_on');
};
exports.off = function () {
    return irsend.send_once('directv', 'key_off');
};
exports.ok = function () {
    return irsend.send_once('directv', 'key_ok');
};
exports.channel = function (channel) {
    var cmdExec = new CommandExecutor();
    if (channels[channel]) {
        var numberStr = channels[channel];
        for (i = 0; i < numberStr.length; i++) {
            var number = numberStr[i];
            var btn = 'KEY_' + number;
            cmdExec.addCommand(irsend.send_once_data, {remote: 'directv', key: btn}, 200);
        }
    }
    return cmdExec.execute();
};

To Summarize

The server will get a JSON with the command to execute and the data that needs to execute the command. The commands are 'complex' actions that require one or more actions over the devices. The devices uses irsend directly to resolve the actions or the commandExecutor if multiple key presses are required (like changing the channels).

You can get the code here. Please comment if you have any issues/questions/whatever using this. I'll try to respond them asap.

1 comentario: