Network Protocol, Revisited

This is the first of three posts that will bring together the threads of the Layout Control Nodes series to create a small demonstration network and show what it can do.

In the last installment of series, I proposed a method and language LCN’s can use to communicate and coordinate activity. We came up with a simple, 4 element packet structure and elected to use a human readable, delimited text format based on the theory it would ease debugging.

Let’s revisit that format decision; the alternative is a fixed length binary format.

In the Arduino world, where our development environment depends on serial communication, it often seems that message streams are easier to debug if they are in a text format rather than a binary format. But appearances can be deceiving and the trade-offs may not be obvious.

Text Messaging

Let’s say you want command a turnout to change alignment to CLOSED. Our four element packet in text form would look like this if the turnout id is 0: “t,0,2,1”; or like this if the turnout id is 15: “t,15,2,1”. Notice that the two text messages are different lengths; the first is 7 bytes (4 numeric characters and 3 commas), and the second is 8 bytes (5 numeric characters and 3 commas). This presents challenges when converting messages to data.

Parsing a Text Message

Consider a data structure called PACKET:

struct PACKET {
  uint16_t node;
  char object; 
  byte id;
  byte function;
  byte data; 
};

As you can see, the data structure can hold all four elements of a message plus the Node ID of the source node. Accordingly, a parser to extract data from a text message and place it in the PACKET data structure might look like this:

String delimiter = ",";
struct PACKET parsePKT(String packet) {
  PACKET pkt;
  String item;
  int counter = 0;
  int lastDelim = -1;
  // find first delimiter
  int delimIndex = packet.indexOf(delimiter);
  // drop leading delimiter, if any
  if(delimIndex == 0) { 
    packet.remove(0,1);
    delimIndex = packet.indexOf(delimiter);
  }
  // loop through the message, finding and counting delimiters,
  // and extracting data items
  while(delimIndex >= 0) {
    // extract the substring between delimiters
    item = packet.substring(lastDelim + 1, delimIndex);
    switch(counter){
      case 0:
        pkt.object = item.charAt(0);
        break;
      case 1:
        pkt.id = byte(item.toInt());
        break;
      case 2:
        pkt.function = byte(item.toInt());
        break;
      }
    counter++;
    lastDelim = delimIndex;
    delimIndex = packet.indexOf(delimiter, lastDelim + 1);
    // capture data after the last delimiter
    if((delimIndex == -1) && ((lastDelim + 1) <= int(packet.length()))){
      item = packet.substring(lastDelim + 1);
      pkt.data = byte(item.toInt());
    }
  }
  return pkt;
}

This approach has some obvious problems.

One thing that should stand out for you is the effort required to accommodate multi-character numeric values, and the potential problems arising from format conversions. It takes 26 code lines, 15 lines of which are a loop that iterates 3 times to get all 4 parts of a message. Call it 56 steps to convert a 4 element text message to usable data.

That process takes time; the longer it takes to parse a message the greater the likelihood you’ll miss a subsequent message while processing the prior one. I should add that most of the time you have to convert a text string to another format or value type (such as an integer); conversions are a frequent source of bugs.

Thinking about potential future needs, adding additional elements to a message would require significant reworking of the parsing algorithm. All of this effort is justified by the theory that it makes debugging easier.

After due consideration, I have to say we’re all mistaken on this point.

Binary Messaging

If you construct a message in binary form, the element values are the actual value bits, not a text representation of the value. For example, in binary form, the value 255 would be passed as 8 bits: 11111111 — 255 in base 2 — a single byte of data. In text form, you would pass the same value as 3 bytes — ‘2’, ‘5’, ‘5’ — one for each numeric character.

The size, in bytes, of a binary value depends on its C++ type. A single byte can represent signed values from -127 to 127, or unsigned values from 0 to 255. The integer type on a 16 bit MCU requires two bytes, allowing you to pass signed values from -32,768 to 32,767, or unsigned values up to 65,000 in a two byte message element. Long types (signed or unsigned) require 4 bytes. It should be obvious that binary values are the most efficient way to pass data around.

The elements of an LCN message — object, id, function, data — are either a character (‘s’ for signal, ‘t’ for turnout), or a number less than 255. Accordingly, in binary form the message is 4 bytes long, laid out like this:

  • Byte 0: a character identifying an object type
  • Byte 1: Numeric id of the object, 0 – 255
  • Byte 2: Function number, 0 -255
  • Byte 3: Data, 0 – 255

The efficiency becomes undeniable at the parsing stage because you simply grab literal bytes and stuff them into a data structure or other variables. In this example, a pointer to the destination data structure is passed to the parsing function, along with the message header containing the id of the source node:

void parsePKT(byte *data, PACKET *dest, RF24NetworkHeader header) {
  dest->node = header.from_node;
  dest->object = data[0];
  dest->id = data[1];
  dest->function = data[2];
  dest->data = data[3];
}

5 steps to extract all the elements of the message.

Compare the two parsing methods. Is there any doubt that the binary messaging format will be simpler, faster and less prone to errors?

Debugging

Debugging a binary message stream is not difficult because the String object, Serial.print() and Serial.println() convert values to their their ASCII text representations automatically. To dump a 4 byte binary message to a line on Serial Monitor so you can verify its values, you need only do this:

for(int i = 0; i < PACKET_SIZE; i++){
  Serial.print(message[i]);
  Serial.print(',');
}
Serial.println();

If the message concerns a turnout object, the first element will be 116, the ASCII code for lower case ‘t’. You can cast the value to a character and get a ‘t’ instead of 116:

Serial.print(char(message[0]));

To reassemble multiple bytes into a larger value, such as a signed or unsigned integer, you have to know the order of the bytes. Byte order is expressed as MSB (Most Significant Byte first) or LSB (Least Significant Byte first). For example, the unsigned integer 258 is composed of a high byte of 1 and a low byte of 2. In MSB order, the high byte is first, so the two individual bytes would be 1,2. To recompose the value:

(data[0] * 256) + data[1] == INT Value
(1 * 256) + 2 = 258

The better technique is to bit shift the high value then add the low:

(data[0] << 8) + data[1]

Accordingly, to extract a 2 byte uint16_t value from a packet of bytes in MSB order and display as an OCTAL ID , do this:

uint16_t nodeID = (buffer[0] << 8) + buffer[1];
// print the id in OCTAL format
Serial.println(nodeID, OCT);

Bottom Line: so long as you have a way to see message content in text form, you can debug your application, and Arduino tools make it easy to see a text representation of most value types. Sending messages as text would not actually make debugging easier, and it would come at the cost of larger messages, more parsing code, diminished performance and potential for error.

A Practical Example

We need a practical example for exploring multi-node communication. Rather than talking about layout networking in the abstract, I’ll create a simple network of 3 nodes — a MASTER (required for the nRF24L01+ network) and two CLIENT nodes that implement the Signal Example from the last post where two turnouts and two signals interact:

The Signals face away from each other, protecting the approaches to and transit through block 23. Both turnouts are protected by other signals at their frog end (not shown or modeled), so we can allow a train to approach the farther turnout.

Client #1 will host the West Signal and Turnout, and Client #2 will host the East Signal and Turnout.  Each will listen for turnout commands, will broadcast changes in the state of it’s turnout, and will listen for state changes broadcast by the other turnout.

As discussed in the prior post, turnouts are tri-state objects, whose state will be one of the following values:

  • ALIGN_NONE [0]
  • CLOSED [1]
  • THROWN [2]

Finally, the MASTER will host buttons to command the turnouts.  A button push will cause a turnout to toggle position; throw it if CLOSED, or close it if THROWN. We’ll attach to the serial monitor on the MASTER to further observe message traffic.

Demonstration Module

To make this a real world example, I’ve created a small demonstration module that models the example track section. The module consists of two turnouts, with opposing frogs, joined by a short track section, with a signal on each side as shown. I’ve angled the signals toward the front to make recording the demonstration on video a little easier.

Two Node Demonstration Module
Demonstration Module West
Demonstration Module East

I’ve created a separate MASTER module with two buttons to command the turnouts.

Demonstration Module Master

The Hardware

As described in Layout Control Nodes, each node consists of a Nano and an nRF24L01 radio mounted on an I/O expansion base. There are several types of Nano IO Expansion boards supporting nRF24L01 radios. For example:

A Generic Nano IO Expander with nRF24L01 support.

Bases like these work reasonably well with a caveat: they do not include active power management components. Consequently, the Nano’s power management components do all the work and they are not entirely adequate for the task.

The nRF24L01 radio requires a 3.3 volt power supply. The Nano’s 3.3 volt circuit is problematic because the source for that power is the USB chip rather than a dedicated power regulator. USB chips typically produce 3.3 volt power for internal use, derived from the standard 5 volt TTL power used for USB connections. Usually there is some excess capacity, and the Arduino designers elected to make that available for use via a pin. However, the amount of current available at 3.3 volts is limited, typically to about 50 mA.

So here’s the issue: the radio draws 20 mA when idle or listening; so far so good. However, when transmitting the current draw spikes to about 120 mA. Obviously, that spike will be a problem without help.

On the generic board you will see a small, metal bulk capacitor sitting next to the yellow radio header. That capacitor provides the short term boost when the radio demands it. It takes some time for the capacitor to recharge after it is drawn down. so I have noticed some performance degradation when transmitting frequently with this equipment.

I am using a different base, one that I developed as part of a larger project. The base is available as a partially assembled kit at my commercial website.

DNOI1.210 Advanced Kit Assembled.

What Makes this Base Special?

Power management. I learned the hard way the nRF24L01 radios are sensitive to the quality and capacity of their 3.3 volt power supply. The small bulk capacitor on a generic board provides just enough filtering and reserve power for the radio to work with the caveat noted above.  My board includes a large bulk capacitor for buffering power to the entire board, plus a regulated 3.3v power supply, with filtering, to support the radio. The 3.3v supply has sufficient capacity to support up to 500 mA supply for other 3.3v accessories and peripherals.

Rising Cage Terminals. The terminals we use are the “rising cage” type; a metal cage inside the terminal rises to press the wire against a metal plate at the top, resulting in a strong connection that easily holds all kinds of wire, including ultra thin magnet wire and stranded wire. These are a little more expensive—but a lot more effective, especially with the smaller wire gauges common to model railroading—than the “compressed tongue” terminal type you find on most boards.

DNIO1.210 Board Unassembled

Coding Strategy

This demonstration requires two sketches. The first sketch is for the MASTER. The second sketch will be for both CLIENTS, using configuration values appropriate for each. All the networking code will be in a common file, that both sketches will share.

I’ll dig into the code in the next installment in this series. Until then, Happy Model Railroading!

Leave a Reply

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