Sketching a Network

This is the second of a three post series tying together the Layout Control Nodes series to create a practical layout network.

In the last post in the Layout Control Nodes series, I showed you why binary messaging is the best choice for multi-node communication. In this post I’ll dig into the shared networking code that allows the nodes to communicate with each other.

The Demonstration Module

To illustrate how multiple independent nodes can work together to manage layout objects, I’m creating a simple network of 3 nodes to implement a Signal Example where two turnouts and two signals interact:

Interchange Example

I’ve also created a module to model the interchange — specifically T1, T2, the track between and the signals on opposite ends. The two controllers on the module will receive the CLIENT sketch (with a few configuration variables to distinguish between the controllers).

Two Node Demonstration Module

Messaging Types

Messaging occurs in two forms:  point-to-point messages exchanged between two nodes, and one-to-many multicast messages.

Multicasting simplifies the process of propagating a message to multiple nodes, and is supported by specific features of the RF24Network network drivers. In a distributed environment, the ability to blast a message that everyone can see would seem to be be the essential feature for coordinating multiple nodes.

Let’s recall the structure of a RF24 network.

RF24 Network Structure. See documentation at https://nrf24.github.io/RF24Network/

The addressing scheme groups nodes into levels, from level 0 (the MASTER only) to level 4. Levels 5 and 6 are available for custom grouping of nodes from around the network. Multicasting is accomplished one level at a time:

for(int level = 0; level <= 4; level++){
    RF24NetworkHeader header;
    network.multicast(header, data, PACKET_SIZE, level);
  }

This adds some flexibility: you can limit multicasting in any given situation or one or more specific levels. You can also multicast to levels in any order.

Having told you why you might need multicasting, I now have to tell you to keep it under fairly tight control and use it sparingly. Multicasting works best when the other nodes are non-critical listeners with respect to the multicast message. More on that below.

Protocol Adaptations

Since multicasting is a different function from standard point to point messaging, we want to add some features to our protocol to enable proper handing of multicast messages. Now we should keep closer track of the origin and addressing of messages, so we’ll modify the PACKET data structure we use internally to include both addressing elements:

struct PACKET_DATA {
  uint16_t from_node;
  uint16_t to_node;
  byte object; 
  byte id;
  byte function;
  byte data; 
};

Additionally, we can use of the RF24Network message header to differentiate between multicast and point to point messages. The header includes an unsigned char “type” field that we can use to flag multicast messages.  The type field is generally used by the system to determine whether or not the network acknowledges messages as they are transported. Use a value between 65 and127.  You can use field values to distinguish between and route messages.

For this demonstration I’ll use these values:

#define MTYPE_NORMAL 65 // node-to-node messages
#define MTYPE_MULTICAST 66 // multicast messages

Transport Reliability

At this point I need to address the issue of message transport reliability. The RF24Network transport protocol lacks a delivery guarantee mechanism (unlike TCP/IP, for example). There is a built-in network ACK system that can be activated, but even that does not guarantee message delivery because the ACK might not get returned for lack of deeper delivery support in the RF24Network .protocol.

Consequently, delivery failures are common whenever there are more than two nodes communicating with each other. Some causes of network failure are easy to address at installation; others are addressed through management of the transmission process using tools provided by the RF24Network class.

Basic issues affecting transmission reliability include:

  • Inadequate power supply to the nRF24L01+ chip. As explained in the previous post, The classic Arduino boards — UNO, Nano or Mega — get 3.3v power from the USB chip. USB chips use 3.3v power internally, derived from the 5 volts TTL provided by the USB hub when connected. This is a poor source for 3.3v power, and it cannot produce enough current to meet radio demand which spikes during transmission.
    • The minimalist solution is a small bulk capacitor next to the radio’s 3.3v input. This solution breaks down under the load of frequent transmission because the capacitor can’t recharge and discharge fast enough.
    • The right solution is a separate regulated 3.3v power supply with at least 200mA current capacity. The boards I’m using supply up to 800 mA current at 3.3v, more than needed for the hungry radio, so there is extra capacity for other accessories.
  • Unfiltered Power Supply. The other reason for a capacitor on the 3.3v feed is to filter any noise in the power supply. The radio chip hates power supply noise. Don’t use a carrier board that does not have a filter capacitor on radio power.
  • Power Amp Setting Too Low. The default setting for the radio power amplifier is too low for reliable multi-node communication. When initializing the radio, set the power amp to RF24_PA_HIGH, or RF24_PA_MAX in difficult or outdoor locations.
  • Physical Obstructions. Radios generally prefer a clear line of sight between antennae. Don’t expect radio signals to penetrate heavy obstructions, so place your radios so they can “see” each other. You can get around obstructions by using the radio network to your advantage and relaying signals from node to node around the problem area.

You can address all of these issues and still have transport failures. Sometimes, a node you are transmitting to can’t read the messages fast enough. Even though the nRF24L01+ chip has 3 FIFO buffers to hold messages, as soon as those buffers fill the chip rejects additional messages until the buffers are read. At other times, simple radio interference damages the data during transport, causing transport failure.

You have several tools available to obtain high transport reliability. The most important tool is the network.update() method. This method must be called regularly to process radio traffic and participate in the network. At a minimum, network.update() must be called once each loop cycle; add more calls in loop() if your loop code is complex and takes a while to execute. Additionally, always call network.update() before attempting to read or write to the network.

The other tool available is the boolean return value from both network.write() and network.multicast(), which return true if the transmission was successful, or false if not. Since the protocol doesn’t guarantee delivery, what this return value tells you is a) whether or not transmission was attempted, and 2) whether the transmission completed normally from the radio’s point of view.

You can use the return from network.write() and network.multicast() to determine if transmission should be retried. This is most effective when sending point to point messages with network.write().

What do you do with the results value? It is tempting to just bang away until transmission succeeds. That would not likely work as intended and would tend to exacerbate network traffic issues. Instead, you should wait for a brief random period before retrying so that the condition inhibiting transmission can clear. That delay should be followed by a call to network.update(), then the next transmission attempt.

You should set a reasonable number of retries before giving up. I find that something between 15 and 25 retry attempts is enough to get to the reliability level I need without slowing the network down. I’ll use 15 for this demonstration; arriving at a perfect value takes a little trial and error.

Accordingly, the netSend() function now should look like this:

#define MAX_TRANSMIT_RETRIES 15
void netSend(byte *data, RF24NetworkHeader header){
  int counter = 0;
  boolean result = false;
  network.update();// process current traffic on the chip
  do {
     // attempt to write the data to the network
     result = network.write(header, data, PACKET_SIZE); // Send the data
     if(!result){
      // on failure, delay a random number of microseconds
      // then update and retry
      delayMicroseconds(random(5, 30));
      network.update();
    }
    counter++;
  } while(!result && counter < MAX_TRANSMIT_RETRIES);
}

Multicasting Reliability

The forgoing technique which works well with point-to-point messages does not work with multicast messages. The reason seems to be that the multicast() utility can successfully transmit without any guarantee that all nodes received the message. Unlike the point-to-point write() function, the multicast function rarely reports failure.

A multicast message is actually a single message transmitted to a special address that is shared by members of each multicast level group. On a single transmission there is a good chance that a receiving node can miss the message.

Accordingly, I modified the broadcast() function to retry if multicast() returns false, but I’m limiting it to a single retry since multiple retries have proven useless.

void broadcast(byte *data){
  boolean result;
  network.update();
  for(int level = 1; level < 2; level++){
    RF24NetworkHeader header;
    header.type = MTYPE_MULTICAST;
    result = network.multicast(header, data, PACKET_SIZE, level);
    if(!result){
      delayMicroseconds(random(5, 30));
      network.update();
      network.multicast(header, data, PACKET_SIZE, level);
    }
  }
}

The two clients on the demonstration module are both level 1 nodes, so the utility only multicasts to level 1.

Sending Messages

To be able to include both original source and destination addresses in a message, the PACKET_SIZE is now 8 bytes; 2 bytes for each address and 4 bytes for the message content.

The basic interface for sending a message is the sendPacket() method. This method takes two arguments, a pointer to a PACKET_DATA structure containing all of the data required for the message, and a boolean value multicast to flag the message as a multicast message. The multicast argument is false by default, so for point-to-point messages, sendPacket() can be called with just a pointer to a PACKET_DATA structure.

void sendPacket(PACKET_DATA *pkt, bool multicast = false){
  RF24NetworkHeader header;
  byte outBuffer[PACKET_SIZE + 1];
  outBuffer[0] = highByte(pkt->from_node);
  outBuffer[1] = lowByte(pkt->from_node);
  outBuffer[2] = highByte(pkt->to_node);
  outBuffer[3] = lowByte(pkt->to_node);
  outBuffer[4] = pkt->object;
  outBuffer[5] = pkt->id;
  outBuffer[6] = pkt->function;
  outBuffer[7] = pkt->data;
  
  if(multicast){
    broadcast(outBuffer);
  } else { 
    header.type = MTYPE_NORMAL; // Node-to-Node Message
    header.to_node = pkt->to_node;
    netSend(outBuffer, header);
  }
}

After loading the packet buffer, sendPacket() checks the multicast argument. If this is a multicast message, it passes the packet buffer to the broadcast() function for immediate multicasting. Otherwise, the function transmits the message via netSend().

Multicasting Strategy

Since multicasting is not as reliable as point-to-point messaging, it should be used sparingly and only for low priority messages. So, for example, you wouldn’t multicast a command meant for only one node, such as a command to align a specific turnout.  On the other hand, once commanded, the turnout object needs to inform others of its state.

Message prioritization is necessary. When a turnout changes state, who needs to know?  Clearly, another node running a signal linked to the turnout MUST know.  On the other hand, a control panel indicator would LIKE to know; as would an animated feature that is keyed to the turnout.  So an obvious and easy way to prioritize is to evaluate messages relative to their impact on the trains.  Things that affect train operation must be reliably communicated to specific partner nodes. Non-critical listeners can afford to miss a message, though we would prefer they did not.

For this demonstration, the two Clients directly communicate regarding turnout positions and multicast for general non-critical information purposes only. When a node needs to send an informational message, it will send the message point-to-point to its critical partner, then broadcast the same message to everyone. I’m improving multicasting performance by generating a duplicate multicast from the MASTER in response to a multicast from a Client.

Receiving Messages

In the main loop(), call function pollNet() frequently — at least once per loop iteration — to run the message pump and receive messages addressed to the node. Like sendPacket(), pollNet() first checks a new message to see if it is a multicast packet.  If so, and the current node is the MASTER, the function repeats the message as a new multicast message. Any packet received by pollNet() is then returned to the caller in a PACKET_DATA structure.

PACKET_DATA pollNet(){
  byte packetBuffer[PACKET_SIZE + 1]; 
  PACKET_DATA pkt;
  pkt.object = 0;
  network.update(); // Process network data
  while( network.available() ) { 
    // Get available incoming data for this node
    RF24NetworkHeader header_in;
    network.read(header_in, &packetBuffer, PACKET_SIZE); // Read the incoming data
    parsePKT(packetBuffer, &pkt, header_in);
    if(header_in.type == MTYPE_MULTICAST){
      if(this_node == 00){ 
        // Multicast the packet
        // Original Source packet element updated to reflect actual message source
        packetBuffer[0] = highByte(pkt.from_node);
        packetBuffer[1] = lowByte(pkt.from_node);
        broadcast(packetBuffer);
      } 
    } 
  }
  return pkt;
}

As shown in the prior post, the binary parser is simple and fast. To adapt to the new protocol requirements, the parser now sets the “from_node” element of the packet using the header from_node field.

void parsePKT(byte *data, PACKET_DATA *dest, RF24NetworkHeader header) {
  dest->from_node = header.from_node;
  dest->to_node = (data[2] << 8) + data[3];
  dest->object = data[4];
  dest->id = data[5];
  dest->function = data[6];
  dest->data = data[7];
}

Handling Messages

Once you have a message from the network to process, what do you do? As with other steps in the process, efficient handling of message content is key to ensuring nodes spend enough time listening to the network that they don’t miss messages.

In third and last part of this series of posts, I’ll look at how the Master and Clients each handle message content. Then we’ll define turnout and signal objects and I’ll demonstrate everything working together. I’ll post all the code in the next post.

Until then, Happy Model Railroading!

2 thoughts on “Sketching a Network”

  1. Hi, I am an American now living in Brazil. I am currently building a large (37′ x 26′) HO layout. The main difficulty I’m having is that one cannot buy assessory items from, say, Digitrax, NCE, MERG, Tam Valley Depot without paying a 100% import tax on the item’s cost PLUS shippling costs. BUT, arduino products are easily bought in Brazil.

    So, I would like to install a LCN on the layout following your instructive posts here. I am, however, alone knowlege wise here in Brazil. I am asking whether you would be open to allowing me to contact you questions I may have on installing the LCN — an online consultant so to speak. If you require, I am will to pay a fee for your time and advice. Looking forward to your reply.

    • Hi Ken, I’m always happy to try to help. If the question is related to online content, feel free to ask here. You can also reach me at robATthenscaler.com. I wouldn’t ask for fees for advice, but if you need me to create something custom for you, then that would be a different thing. In any case, ask away!

Leave a Reply

Your email address will not be published. Required fields are marked *