Long Range Wireless CAN Bus Bridge

Long Range Wireless CAN Bus Bridge

During my time on the WMU Formula SAE racing team, I started to learn the value of dumping nearly all data we could ever need onto the vehicle's CAN bus. This made firmware development super easy becuase any module could control or query data from any other module. Additionally, we could plug in a laptop with a USB to CAN tool and graph the data live with a tool I made that leverages Grafana.

I wanted to use this exact same tool while not directly connected to the car, so that we could monitor software states, coolant temps, exhaust gas temperatures, and tons of other data live. This way, we don't use two different tools when connected to the car over a CAN wire or over the wireless telemetry system.

To accomplish this, I designed a transciever board, transmitter firmware, receiver firmware, and a small helper library.

Links:

top side
bottom side

The hardware is based on a low frequency, high range 900Mhz XBEE radio transceiver. This module is stupid simple to use, and basically acts like a wireless UART cable. We managed to get over half a mile of range through trees and over the elevation changes of the WMU engineering campus with small and cheap 2.1dBi gain antennas on either end. The whole thing has 4 pins that connect to the car: CAN high, CAN low, +12V, and gnd.

Range test 

This system proved to be more than reliable enough for our testing, as most of the lots we tested in were wide open, and had us posted near the center of the track to pull telemetry while the driver ran laps.

Firmware

For this project, there were a few challenges with the firmware. First, due to the limited bandwidth of the XBEEs, I wanted to only send a subset of the car's available CAN bus frames. Next, I needed to buffer CAN data in order to pack it all into one large binary string and send it in a single packet. This is due to the inter frame wait time that the XBEE requires, along with the relatively small max packet size of 256 bytes of the XBEE.

testing to confirm the 256 byte packet maximum

For the above test, I set up a micro to continously send data to the transmitter, but we see on the receiver that the data is chunked into 256 byte packets with a 6ms inter frame delay. This means we can't just stream CAN data without any pre planning.

To deal with this, the transmitter firmware builds a map containing only the CAN IDs that I define as relevant. For code readability I chose to use a C++ map which requires dynamic memory allocation (I did not want to use a custom allocator here). This is frowned upon, but I was aware of the application's boundaries and knew this wouldn't lead to any issues, especially with the huge RAM size of the STM32F303RE we are using here.

// Takes an MBed style CAN message and updates the map if we care about the ID
void updateCanMap(CANMessage *msg) {
  CANFrame tempFrame = {0};

  // Don't add any new values that weren't in the map when we initialized it
  if (canData.find(uint32_t(msg->id)) != canData.end()) {
    // Copy data to serializable frame format
    if (msg->format == CANExtended) {
      tempFrame.extended = true;
    } else {
      tempFrame.extended = false;
    }

    tempFrame.id = msg->id;
    tempFrame.dlc = msg->len;
    memcpy(tempFrame.data, msg->data, msg->len);

    fillChecksum(&tempFrame);
    canData[tempFrame.id] = tempFrame;
  }
}

This way we can read from the map at any moment and know we have the latest data frame of each ID. This is especially important because each of the frames comes in at a random time, and at any frequency from 10hz to 100hz on this race car.

  while (1) {
    // if a message is available, log it.
    if (can.read(inmsg)) {
      updateCanMap(&inmsg);
    }

    // time to transmit data
    if (timer.read_ms() >= TRANSMIT_INTERVAL_MS) {
      timer.reset();

      // light LED while transmitting a packet
      led.write(1);

      // iterate through entire map
      uint8_t i = 0;
      for (const auto &[key, value] : canData) {
        // copy each CANFrame struct to the output buffer
        memcpy(packetBuf + i, &value, sizeof(value));
        // offset output buffer destination by the size of the frame we just
        // added
        i += sizeof(CANFrame);
      }
      xbee.write(packetBuf, sizeof(packetBuf));

      led.write(0);
    }
  }
}

When it's time to transmit, we copy a bunch of CANFrame structs to the output buffer, then shoot it off with the XBEE.

To keep this code simple, I made a portable can serializer module used here. This contains a platform agnostic CANFrame struct and means to checksum and validate the data. The cpp file is small, and works well:

/**
 * @file canSerializer.c
 * @author Zain Ahmed
*/

#include "canSerializer.h"

// Calcuates a checksum
uint8_t calculateChecksum(CANFrame *pFrame)
{
    uint8_t computedChecksum = 0;                                           // will hold our checksum as we calculate
    uint8_t bytesToSum = sizeof(*pFrame) - sizeof(pFrame->checksum);        // do not include the checksum when calculating the checksum

    uint8_t *bytePtr = (uint8_t *)pFrame;                                   // get a pointer to struct so we can access individual bytes

    for (int i = 0; i < bytesToSum; i++) {
      computedChecksum += *(bytePtr + i);                                   // dereference and sum each byte
    }

    return computedChecksum;
}

/**
 * @brief adds checksum and syncWord to packet. 
 * @param pFrame: Pointer to a CAN frame.
 * @note: Ensure all other data of frame is filled before calling fillChecksum().
*/
void fillChecksum(CANFrame *pFrame)
{
    pFrame->syncWord = 0xA55A;                                              // set the sync word before summing
    pFrame->checksum = calculateChecksum(pFrame);                           // append the checksum to the packet
}

/**
 * @param pFrame: Pointer to a CAN frame.
 * Returns true if the pFrame struct is valid, false if it is invalid.
*/
bool validateChecksum(CANFrame *pFrame)
{
    if (pFrame->checksum == calculateChecksum(pFrame))
    {
        return true;
    }
    else
    {
        return false;
    }
}
canSerializer

When receiving data, there is one tricky thing to deal with. When signal drops, or if the transmitter is power cycled, the tx and rx devices will no longer be synchronized to the start of a packet. This means that the receiver needs to iterate over every incoming byte until it finds a valid start of packet. Once it finds a start of packet, it will build a trial CANFrame, then calculate the checksum of this trial frame. If the checksum is good, we continue. If not, the loop will iterate one single byte forwards and try again. This way it never takes more than 1 dropped frame to recover from signal loss.

int main()
{
    initBoard();
    timer.start();
    xbee.attach(xbeeISR, SerialBase::RxIrq);                                // dynamically set receive interrupt for XBEE transceiver

    while (1)
    {
        can.read(inmsg);                                                    // throw away any incoming CAN frames

        if (xbeeQueue.size() >= 512)                                        // if we fall this far behind there is an issue
        {
            xbeeQueue = {};                                                 // wipe the buffer and attempt to recover
        }

        if (xbeeQueue.size() >= sizeof(CANFrame) * 2)                       // make sure we have enough data to build a packet
        {
            CANFrame tempFrame = {0};                                       // empty trial frame

            for (int i = 0; i < sizeof(CANFrame) - 1; i++)
            {
                frameBuf[i] = frameBuf[i + 1];
            }

            frameBuf[sizeof(frameBuf) - 1] = xbeeQueue.front();             // pull the latest value off the queue
            xbeeQueue.pop();                                                // delete it from queue

            memcpy(&tempFrame, frameBuf, sizeof(CANFrame));                 // build trial frame
            buildFrame(&tempFrame);                                         // build output frame if the checksum is valid
        }

        if (timer.read_ms() >= 500)                                         // we haven't gotten a frame in a while
        {
            led.write(0);
        }
        else
        {
            led.write(1);
        }
    }
}
Rx code

To keep things simple, the receiver chunks incoming bytes onto a queue as they are received, then operates on the data in the superloop. The queue is dynamically allocated, but I have a very conservative boundary (for this micro) of 512 bytes set which causes the whole queue to be wiped out.  

Conclusion

Starting this project was a little daunting for me as a college student with very little wireless experience. These XBEE modules made it super simple, and provided a nice GUI to set up the modules themselves. After a little bit of code, this system proved to be very useful at testing days.