Remote Procedure Calls

Remote Procedure Call (RPC)-related functionality is a fundamental part of SNAP and SNAPpy, and there are many types of SNAPpy built-in functions that can be used to invoke a function on another device.

Introduction

All public functions, including the SNAPpy built-in functions, are remotely callable using the SNAP RPC protocol. Non-public functions (prefixed with underscore) are not remotely callable, but they can be called by other functions within the same script.

These are non-blocking functions. They only enqueue the packet for transmission and do not wait for the packet to be sent (let alone wait for it to be processed by the receiving device(s)). Each of these functions can be used to invoke any public function on another SNAP device (either a user-defined function or a built-in function), but each has additional strengths, weaknesses, and capabilities.

It is important to provide the correct number of arguments when calling a remote function. Issuing a command with the wrong number of arguments will not work, because the receiving device will not be able to find a function signature that matches.

This also applies to SNAPstack-related programming. Make sure that any RPC handlers defined in SNAPstack applications accept the same number and type of arguments that the remote callers are providing. For example, if your script takes two arguments:

def displayStatus(msg1, msg2):
    print(msg1 + msg2)

But the SNAPpy script makes RPC calls with three parameters:

rpc(SNAPSTACK_ADDR, "displayStatus", 1, 2, 3)  # <- too many parameters provided

Or just one parameter:

rpc(SNAPSTACK_ADDR, "displayStatus", 1)  # <- too few parameters provided

Then the displayStatus() function will not be invoked function at all.

Calling By Name

You cannot invoke a function that a device does not have loaded. SNAP devices without scripts only support the “call by number” method to call RPC functions. The name lookup table that allow devices to use “call by name” is sent to the node when a script is loaded. This means that if you want to call a SNAPpy built-in function by name, a remote device needs a script loaded, even if the script is empty.

Note

In the case of mcastRpc(), the device will silently ignore any function that it does not know how to call. If sent via one of the unicast mechanisms (rpc(), callback() or callout()) the packet will be acknowledged but then ignored.

Realize that if you multicast an RPC call to function “foo”, all devices in that multicast group that have a foo() function will execute it, even if their foo() function does something different from what your target device’s foo() function is expected to do. Giving your SNAPpy functions distinct and meaningful names is recommended.

Selecting the Type of RPC

The reliability of message delivery is an important consideration in selecting the best way to send the message. Multicast messages generate no explicit confirmation of receipt from any other device in your network. If you want to be sure that a particular device has heard a multicast RPC call, you must provide that confirmation as part of your own application, generally in the form of a message sent back to the originating device.

Unicast RPC calls are addressed to a single target device, rather than being open to all devices that can hear the request. The reliability of these calls is bolstered by a series of acknowledgement messages sent back along the path, but even that is no guarantee of a final receipt of the message, especially in a dynamic environment.

Multicast RPC

The built-in function mcastRpc() is best at invoking the same function on multiple devices from a single invocation. The trade-off is that the actual packet is usually only sent once 1. How many devices actually perform the requested function call is a function of three factors:

  1. Number of hops

    1. How many hops (forwards or retransmissions of the message) away are the other devices?

    2. How many hops did you specify in the mcastRpc() call? (TTL parameter)

  2. Script existence

    1. Is there a function with that name in the device’s currently loaded script?

    2. Are you providing the correct number of arguments?

    3. What function name and arguments did you specify in the mcastRpc() call?

  3. Group membership

    1. What Multicast Groups do the other devices belong to?

    2. What candidate groups did you specify in the mcastRpc() call?

    3. If the destination device is more than one hop away, do intermediate devices forward the specified group?

The following is an example of using multicast RPC to increase the synchronization of the devices. Not all of the devices will necessarily be running the startDataCapture() function at the same moment, because the closer devices will be hearing the command sooner than the devices that require more mesh network hops. All devices in group 2 that can be reached within 4 or fewer hops and that have a function named startDataCapture() in their script will invoke that function:

mcast_groups = 2
hop_count = 4

mcastRpc(mcast_groups, hop_count, "startDataCapture")

This next example is a little exaggerated, but sometimes you will need to send an RPC to all of the devices because of the importance of the message:

reactor_temperature = get_reactor_temperature()

if reactor_temperature > CRITICAL_TEMPERATURE:
    mcastRpc(1, 255, "runForYourLives")

It is important to note that the function being called might make an RPC call of its own. For the sake of the next example, imagine a network of exactly two SNAP devices, devices A and B, and that device B contains the following script snippet:

def askSensorReading():
    value = readSensor()
    mcastRpc(1, 1, "tellSensorReading", value)

Now imagine that device A contains the following script snippet:

def tellSensorReading(value):
    print("I heard the sensor reading was ", value)

If device A executes the following command, this would result in two-way communication:

mcastRpc(1, 1, "askSensorReading")

First, device A will send askSensorReading() to device B, and then device B will send tellSensorReading() back to node A.

You should also be aware that even though an RPC call is made via multicast, it is still possible to have only a single device completely process that call. Imagine the above two-node network is expanded by adding devices C-Z and that we upload the following script to devices B-Z:

def askSensorReading(who):
    if who == localAddr(): # is this command meant for ME?
        value = readSensor()
        mcastRpc(1, 1, "tellSensorReading", value)

Now, if device A executes the following command:

mcastRpc(1, 1, "askSensorReading", address_of_node_B)

Even though all devices within a one-hop radius will invoke function askSensorReading(), only device B will actually take a reading and report it back.

Footnotes

1

The exception to that rule is if you have enabled the optional collision detect feature of SNAP, in which case the packet might be sent more than once if a packet collision was detected.

Directed Multicast RPC

The built-in function dmcastRpc() functions like mcastRpc() but with two additional features:

  1. You may target one or more specific remote device(s) on which your function should execute.

    If a device does not find its own address in the incoming message, it will still forward the message to other devices (subject to remaining TTL and the restrictions placed by the specified multicast groups), but it will not execute the function, even if the specified multicast groups would otherwise instruct the device to do so.

  2. You can define a delay (in milliseconds) so that the multiple remote devices can respond in a sequential manner rather than all at once.

    The delay only applies to radio communications directly invoked by the remote devices. There is no delay applied to serial communications directly invoked by the remote device. A check of getInfo(25) in the called function on the remote device returns the delay value specified, but it also tells the remote device to ignore the transmission delay that would have normally been enforced.

For example, if you wanted to target devices with addresses 01.02.03, 04.05.06, 07.08.09, and AA.BB.CC, you would need to pass these addresses as a concatenated string:

dmcastRpc('\x01\x02\x03\x04\x05\x06\x07\x08\x09\xaa\xbb\xcc', 0x0001, 5, 40, 'readAdc' , 0)

In the example above, the delay is set to 40 ms, so any radio traffic generated by device 01.02.03 would be released immediately, radio traffic generated by 04.05.06 would be queued and held for 40 ms, radio traffic generated by 07.08.09 would be delayed for 80 ms, and radio traffic generated by AA.BB.CC would be held for 120 ms before release.

Unicast RPC

The built-in function rpc() is like mcastRpc() but with two differences:

  1. Instead of specifying a group and a number of hops (TTL), with the rpc() function you specify the actual SNAP address of the intended target device.

    1. The SNAP mesh routing protocol will take care of “finding” the device (if it can be found).

    2. Other devices (with different SNAP addresses) will not perform the rpc() call, even if their currently loaded SNAPpy script also contains the requested function. However, they will (by default) assist in delivering the rpc() call to the addressed device.

  2. Instead of only sending the RPC call a single time (blindly) as mcastRpc() does, the rpc() function expects a special ACK (acknowledgement) packet in return.

    1. When the target device hears the rpc() call, the ACK packet is sent automatically (by the SNAP firmware – you do not send the ACK from your script).

    2. If the target device does not hear the rpc() call, then it does not know to send the ACK packet. This means the source device will not hear an ACK, and so it will timeout and retry a configurable number of times.

Going back two examples, instead of modifying the askSensorReading() function in device B’s script to take an additional who parameter and calling:

mcastRpc(1, 1, "askSensorReading", address_of_node_B)

Node A could simply call:

rpc(address_of_node_B, "askSensorReading")

Devices C-Z would ignore the function call, although they may be helping route the function call to device B without any additional configuration.

The askSensorReading() function could also benefit from the use of rpc() instead of mcastRpc(). Instead of telling the sensor reading to all devices in group 1 within 1 hop away via:

mcastRpc(1, 1, "tellSensorReading", value)

The script could instead only send the results back to the original requester via:

rpc(rpcSourceAddr(), "tellSensorReading", value)

Function rpcSourceAddr() is another built-in function that, when called from a function that was invoked remotely, returns the SNAP address of the calling device.

Note

If you call rpcSourceAddr() locally at some arbitrary point in time, such as within the HOOK_STARTUP or HOOK_GPIO handler, then it simply returns None.

Callback

The previous examples allowed one device to ask another device to perform a function and then send the result of that function back to the first device. In each case, the first device called the askSensorReading() function, whose only purpose was to call a separate function readSensor() and then send the value back. It turns out that SNAP has a built-in function to do just that, the snappy.BuiltIn.callback() function.

Expanding a little on the previous example, device B’s readSensor() function is pulling its own weight – it is encapsulating some of the sensor complexity, thus hiding it from the rest of the system:

def readSensor():
    return readAdc(0) * SENSOR_GAIN + SENSOR_OFFSET

def askSensorReading():
    value = readSensor()
    mcastRpc(1, 1, "tellSensorReading", value)

Sometimes, the raw sensor readings are sufficient or possibly the calculations are so complex that they need to be offloaded to a bigger processor. In that case, we could change the code to this:

def readSensor():
    return readAdc(0)

def askSensorReading():
    value = readSensor()
    rpc(rpcSourceAddr(), "tellSensorReading", value)

Which can be simplified even further to:

def askSensorReading():
    value = readAdc(0)
    rpc(rpcSourceAddr(), "tellSensorReading", value)

You might wonder why device A could not skip tellSensorReading() all together and just remotely call readAdc(0):

rpc(address_of_node_B, "readAdc", 0)

Although this will result in device B calling readAdc(0), it will not cause any results to be sent back to node A. This is where the callback() function comes in. Let’s replace readAdc with callback:

rpc(address_of_node_B, "callback", "tellSensorReading", "readAdc", 0)

This will result in device B calling readAdc(0), and the results will be automatically reported back to device A via the tellSensorReading() function.  The callback() function requires you to provide the final function to be invoked (“called back”), in addition to the remote function to be called and its parameters. Notice that we only had to add code to device A’s script – we did not have to create an askSensorReading() function on device B at all.

It’s also important to note that callback() is not limited to invoking built-in functions. For example, if we had retained the original readSensor() routine, it could be remotely invoked and the result automatically returned via:

rpc(address_of_node_B, "callback", "tellSensorReading", "readSensor")

Callout

The function callout() just takes the callback() concept one step further. Instead of asking a device to invoke a function and then call you back with the result, callout() is used to ask a device to call a function and then report the result to a third device.

For example, device A could ask device B to read analog input channel 0 and tell device C what the answer is. Imagine that device C has a graphical LCD display that the other devices lack:

rpc(node_b_address, "callout", device_c_address, "tellSensorReading", "readAdc", 0)

In a more complex example, device A could ask device B to find out how well all the devices one hop away could hear node B, and then ask them to send the answers to device A:

rpc(node_b_address, "mcastRpc", 1, 1, "callout", device_a_address, "tellLinkQuality", "getLq")

Node A is asking device B to send a multicast. All devices within one hop of device B will receive a callout() instructing them to call their getLq() function. Each device will take the result of the getLq() and send it to device A as a parameter of the tellLinkQuality(), via an rpc() function. Node A would need to have the function for node B’s neighbors to send their results:

def tellLinkQuality(lq):
    who = rpcSourceAddr()
    # do something with the address and the reported link quality

Depending on the network, device A could expect to receive many messages which will answer the question “how well did node B’s neighbors hear the multicast transmission?”

Directed Multicast Callout

The dmCallout() function was added in SNAP 2.7. This function works very similarly to the callout() function, except that when the remote device invokes a function, it sends the return value to the list of devices as a dmcastRpc() function. As with callout(), this allows you to have the target device ask a remote device to do something and then tell other device(s) how it turned out. The difference being that the remote device will use a dmcastRpc() function instead of an rpc() function to report the result.