Skip to main content

Effective Internet-agent-device Communication

How imp-enabled Devices Interact With The Outside World

Note This article covers communication between devices and Internet-connected resources. If you are interested in connectivity to locally networked devices, please see How To Connect To Devices On A Local Network.

Every imp is able to bring the benefits of Internet connectivity to a wide variety of devices. But imps don’t communicate with the cloud directly. Instead, this contact is delegated to their agents — per-device micro-servers running in the Electric Imp impCloud™.

Think of an agent as an imp’s ‘gopher’. It locates and accesses Internet-based resources on the imp’s behalf, and handles incoming commands and requests for information. This approach allows the imp to devote as little as possible of its runtime and, crucially, its power on Internet communications; instead the agent does all the hard work.

As the developer of the agent code running in the impCloud and the device code running on the imp, you are the author of this dialogue between imp and agent, and between agent and Internet-connected resources.

The imp API makes it easy to send requests to cloud services, but how do agents respond to requests sent to them? And how do agents and devices co-ordinate communication to make sure that, for example, the right data is sent by the device in response to a request from a remote caller? Once again, the imp API provides the basis for the functionality you need, but you will need to write a lot of code if you move past very basic scenarios.

Enter Electric Imp’s Rocky and MessageManager. These free-to-use libraries leverage the imp API to provide powerful and sophisticated tools for, respectively, the creation of agent-served endpoints and managing agent-device communications.

Here’s an example. You want to allow your IoT products to provide sensor readings in response to ad hoc requests from an Internet connected source which may be a browser-hosted app, a command-line tool or a dedicated mobile app. Breaking this task down, we need to code:

  • A way to set up an endpoint URL at the agent and to handle incoming requests.
  • A way to send ‘send me a reading’ messages from the agent to the device.
  • A way to send a reply to each of those messages back to the agent.
  • A means to send the received readings back to the original requester.

Let’s use the Rocky library to set up the outward facing interface first. We’ll do this in a new Development Device Group’s Agent Code.

Note This tutorial assumes you have an Electric Imp account and a development device, and that you know how to work with either the impCentral™ web IDE or Electric Imp’s command line tools. If you’re new to the platform, and this is your first tutorial, we suggest you work through our quick Getting Started Guide first. Both this tutorial and the Getting Started Guide use the imp006 Breakout Board, but are very readily adaptable to other imp-based development boards.

1. Build the API

Rocky allows you to set up a series of endpoints and to register handlers which are called when requests are made to those endpoints. The HTTP verbs GET, POST and PUT are supported directly, and you can use others. You can also register handlers for general events, such as errors, timeouts, unavailable resources or unauthorized requests. Further handlers can be chained to these ones to allow you to assemble complex sequences of code. We won’t use all of this advanced functionality here, but you’ll see how to add such ‘middleware’ to your handlers.

Libraries are loaded using the #require directive. For Rocky, the directive is:

#require "Rocky.agent.lib.nut:3.0.0"

If you’d like to learn more about how the directive is formatted, please see our library usage guide.

Next, alias and initialize the single Rocky instance:

api <- Rocky.init();

Let’s set up an endpoint which expects to receive a JSON payload that specifies what reading we want the device to provide — it has multiple sensors — and whether we want a one reading or a batch of them. The JSON looks like this:

{ "sensor": <"temperature"|"accelerometer">,
  "readings": <number_of_readings> }

The endpoint will be /readings so we set up Rocky as follows:

api.POST("/readings", function(theRockyContext) {
    . . .
});

This tells Rocky to register a handler (function(context) { … }) for POST requests sent to the agent at the /readings endpoint. Each agent has a unique ID, so valid requests must go to https://agent.electricimp.com/<agent_ID>/readings. All other requests will be rejected.

We omitted the handler’s code for clarity, so let’s now look at what should appear in place of . . . Here’s the code; we’ll discuss what it does afterward:

try {
    local requestData = http.jsondecode(theRockyContext.req.rawbody);
    if ("sensor" in requestData) {
        . . .
    } else {
        theRockyContext.send(500, "Request JSON lacks a \'sensor\' field");
    }
} catch (error) {
    theRockyContext.send(500, "Bad data posted: " + error);
}

First, the variable theRockyContext references a Rocky Context — a data structure holding details about the request made to the agent and the response we will use to complete that request. The code uses the imp API http.jsondecode() method to parse the incoming request’s (req) raw body text (rawbody) as JSON. It may not be JSON, of course, which is why we include the call within a try...catch structure so that if the decode fails — the incoming is not JSON or is malformed JSON — we can trap that error and issue a suitable response.

If the JSON decodes, the result is a Squirrel table. We look in the table for the key sensor; if it’s missing, again that’s a request error. We send back a simple string, but a real-world app might send back a detailed report in JSON form. If the decoded data is good, however, we’re ready to communicate with the device, and so it’s time to talk about MessageManager.

2. Message the Device

Again, we load the library with a #require statement:

#require "Rocky.agent.lib.nut:3.0.0"
#require "MessageManager.lib.nut:2.4.0"

It doesn’t matter which order you list these directives, but they must go right at the top of your agent code.

Next, we instance MessageManager with:

api <- Rocky.init();
mm <- MessageManager();

In the second . . . placeholder in the Rocky code above, we can now add code to make use of the MessageManager instance:

mm.send("request.reading",
        requestData,
        {"onReply": readingRequestReplyHandler},
        30,
        theRockyContext
);

readingRequestReplyHandler is a reference to a function that we’ll define in a moment, but the arguments we’ve passed into send() are as follows:

  • The unique message name — this will be used to identify the reply.
  • The data we’re sending to the device.
  • A table of callback functions triggered by message events. We’ve supplied a handler for replies, but you can also include general error, timeout, ‘failed to arrive’ and ‘message acknowledged’ handlers too.
  • A timeout in seconds.
  • Metadata — basically anything you need to bind to a message record. Here we’re including a reference to our Rocky context — which, you’ll recall, records the request made to the agent — so we can use it again later.

As you can see, MessageManager is doing a lot of heavy lifting for us. It’s watching out on our behalf for responses to messages that we send, and is running handlers when a reply is received. It’s also allowing us to bind extra data — the Rocky Context — to the message that remains separate from any data payload we transfer to the device.

Finally, let’s define our message reply handler:

function readingRequestReplyHandler(message, response) {
    local rockyContext = message.metadata;

    local returnedData = {};
    if (typeof response == "array") {
        returnedData.readings <- response;
    } else {
        returnedData.reading <- response;
    }

    rockyContext.send(200, http.jsonencode(returnedData));
}

The ‘onReply’ handler has a specific number of parameters: one to receive the original message that prompted the reply and a second to receive the data payload from reply. In our case, response contains either an array of readings or a single reading, and we use its data type to configure the data we will return.

How do we send that data? We use the Rocky context generated by the original request. Remember, we stored that in the source message metadata, so we get it back and call its own send() method to return our data to the original requestor.

That’s the agent code; now it’s time to see what happens on the device side.

3. Manage Device Messages

Once again, we’ll load up a couple of libraries at the start: MessageManager to partner with the instance running on the agent, and a second library that has the driver code for the on-board thermal sensor.

#require "HTS221.device.lib.nut:2.0.2"
#require "MessageManager.lib.nut:2.4.0"

Now we need to ready the sensor and the I²C bus that connects it to the imp. Don’t worry about the details for now: the important point is that we have a variable, sensor, referencing the driver instance:

hardware.i2cLM.configure(CLOCK_SPEED_400_KHZ);
sensor <- HTS221(hardware.i2cLM);
sensor.setMode(HTS221_MODE.ONE_SHOT);

Lastly, we drop in code to instance MessageManager and set it up to watch for incoming messages like those we defined in the agent code:

local mm = MessageManager();
mm.on(  "request.reading",
        function(message, reply) {
            if (message.data.sensor == "temperature") {
                local numberOfReadings = "readings" in message.data ? message.data.readings : 1;
                local readings = [];

                for (local i = 0 ; i < numberOfReadings ; i ++) {
                    sensor.read(function(reading) {
                        if (!("error" in reading)) {
                            if (numberOfReadings > 1) {
                                readings.append(reading.temperature);
                                if (readings.len() == numberOfReadings) {
                                    reply(readings);
                                }
                            } else {
                                reply(reading.temperature);
                            }
                        }
                    }.bindenv(this));

                    imp.sleep(1.0);
                }

                return;
            }

            reply(999.99);
        }
);

What we do here is register a function that will be called on receipt of a named message — the name we set in the partnering send() call in the agent code. The handler has two parameters which receive, respectively, a MessageManager message record and a function to call to reply to that message. You can see how we call reply() in several places to return data: at the end with an invalid reading as an error condition, elsewhere with either a single sensor reading or an array of sensor readings, depending on the original request from the remote caller.

Early on we check the data payload for instructions: what sensor to read and how many readings to take. Multiple readings are taken at one-second intervals: the imp.sleep(1.0) line pauses for that period of time.

Readings are taken asynchronously: the value isn’t returned by the call to sensor.send() but is passed to the callback function registered with that call. This is because we don’t know when the sensor will be done taking a reading — and when we can reply to the source message. We use Squirrel’s bindenv() — “bind to environment” — method to ensure that the callback has access to the variables within the scope of the bound function, in this case sensor.send(). This allows us to determine if we have the requested number of readings and, when we do, send them to the agent using reply().

With the two blocks of code — one for the agent, one for the device — in place, you can deploy them to the impCloud so they’ll be sent to their targets: click Build and Run in impCentral.

4. Make A Remote Request

Open up a terminal and enter the following command:

curl -X POST https://agent.electricimp.com/<agent_ID>/readings \
-d '{"sensor": "temperature", readings: 5}'

In place of <agent_id> you’ll need to enter the unique ID of your own board’s agent, which is shown in impCentral, at the top of the log pane.

If you’ve entered the code correctly, you should see something like this appear in the terminal:

{ "readings": [ 22.56384, 22.50232, 22.53474, 22.59001, 22.60102 ] }

If you see anything else, check your code. You can find complete listings of the agent and device code here — this version contains some extra code to log information about incoming requests:

2020-10-15 16:19:35.325 +01:00: [Agent] API received a POST request at 1602775175 from 2.101.8.52 at path /readings

Here’s the agent code the displays this:

api.use(function(aRockyContext, next) {
    server.log("API received a " + aRockyContext.req.method.toupper() + " request at " + time() + " from " + aRockyContext.getHeader("x-forwarded-for") + " at path " + aRockyContext.req.path.tolower());
    next();});

The use() method can be applied to any Rocky call:

api.POST("/readings", function(theRockyContext) {
. . .
}).use(function(aRockyContext, next) {
    . . .
    next();
});

In this example, we call it on the main instance so the registered function is triggered with every message, but we could just easily add one that’s triggered only for the POST requests we’re watching for — just add use() to the api.post() call.

Notice that we call next() at the end. This is a reference to the next function in the chain, so you must call it to maintain the sequence of function calls.

Next Steps

We’ve only touched the surface of what Rocky and MessageManager can bring to your application. Check out the Rocky documentation to see how you can register event-oriented functions that will be triggered when, say, a request targets an unsupported endpoint (hint: try the onNotFound() method) or a timeout occurs because the device took too long to respond (onTimeout()). These methods can be used to register handlers for all cases, but Rocky also lets you apply them to specific endpoint paths too.

MessageManager has similar event-oriented handlers that you can add to your code. Try adding some a function that will respond to message acknowledgements (hint: onAck()). You might check the number of readings being requested and if it’s a large number, respond immediately to the requester. They can then make a second, later call to the same or a different endpoint to retrieve the readings taken in the meantime.

Try implementing an onFail() handler to deal with cases where the message didn’t reach the device in the expected time period. You might choose to ask the requester to try again in a few moments, or devise some other strategy for this circumstance.

You might also like to try reversing the message path: MessageManager works just as well with message flows that originate on the device.

Need a hand completing this tutorial? Drop us a line in the Electric Imp Forum.