Experimenting with OpenCyphal

Recently I’ve been looking into using OpenCyphal in my embedded projects.

From the website:

Cyphal is an open technology for real-time intravehicular distributed computing and communication based on modern networking standards (Ethernet, CAN FD, etc.). It was created to address the challenge of on-board deterministic computing and data distribution in next-generation intelligent vehicles: manned and unmanned aircraft, spacecraft, robots, and cars. 

Sounds fancy but why do I want to use it in my hobby projects?

Well over the years I’ve worked on a handful of embedded projects and I always find myself in the same place.

Examples of this would be doing: drone PID tuning or sensor visualization.

Of course to do this you need to use/make a packet protocol that works over serial, get the values then log and plot them. It’s all doable but a bit tedious.

Next obvious thing to do it either reuse a protocol I’ve written or use an off-the-shelf solution. So here we are.

Cyphal has the following that I like:

It’s very ROS like, without requiring the entire ROS ecosystem to use.

Support

As mentioned, Cyphal supports CAN, Serial and UDP. That said, it was historically a CAN based protocol so support is more easily available over CAN.

I’m actually planning to move I projects to CAN but at the moment I’m interested in the serial support.

Integration

Test repo is here

The serial support library is located here: serard

It is also recommended to use for constant time allocation: o1heap

Both of these are intended to be drop in support.

I’m using PlatformIO (because Arduino hates using more then one file) and placed the libraries in the lib/ directory.

Code Generation

Cyphal uses .dsdl files to define message types. A tool called nunavut is used to generate code for supported languages.

nnvg --target-language c --enable-serialization-asserts public_regulated_data_types/reg --lookup-dir public_regulated_data_types/uavcan

Note: Arduino will not like the <> brackets used in the include directives and those need to be modified. This is another reason I’m using PlatformIO.

The headers are places in the include/ directory.

Build Setup

The libraries and headers are in place but I found, even though I was using C code gen, the compiler needed to be set to C++14.

In PlatformIO you can do this using the following in platformio.ini

build_unflags = -std=gnu++11
build_flags = -std=gnu++14

Allocator Setup

#include <o1heap.h>

template<size_t SIZE>
class alignas(O1HEAP_ALIGNMENT) Heap final : public std::array<uint8_t, SIZE> {};
static constexpr size_t HEAP_SIZE = 16384UL;

static Heap<HEAP_SIZE> ARENA;

static O1HeapInstance* heap{nullptr};

static void* serardAlloc(void*, size_t size)
{
  return o1heapAllocate(heap, size);
}

static void serardFree(void*, size_t, void* ptr)
{
  o1heapFree(heap, ptr);
}

static const SerardMemoryResource allocator = {
  .user_reference = nullptr,
  .deallocate = &serardFree,
  .allocate = &serardAlloc,
};

// ...

void setup() {
  // heap_base = new uint8_t[HEAP_SIZE];
  // Allocator setup for serard
  heap = o1heapInit(ARENA.data(), HEAP_SIZE);

}

Serard Setup

#include <serard.h>

// ...

static Serard serard;

// ...

void setup() {
  // Initialize serard
  serard = serardInit(allocator, allocator);
}

Serial TX

#include <uavcan/node/Heartbeat_1_0.h>

// ...
static const SerardNodeID NODE_ID = 5;
static uint32_t last_heartbeat = 0;
// ...

static bool serialEmitter(void* const, uint8_t size, const uint8_t* data)
{
  Serial.write(reinterpret_cast<const char*>(data), size);
  return true;
}

void loop() {
  const auto now = millis();
  if (now - last_heartbeat > HEARTBEAT_PERIOD)
  {
    const uavcan_node_Health_1_0 health = {
        .value = uavcan_node_Health_1_0_NOMINAL,
    };
    const uavcan_node_Mode_1_0 mode = {
        .value = uavcan_node_Mode_1_0_OPERATIONAL,
    };
    const uavcan_node_Heartbeat_1_0 heartbeat = {
        .uptime = now,
        .health = health,
        .mode = mode,
        .vendor_specific_status_code = 0,
    };

    uint8_t buf[uavcan_node_Heartbeat_1_0_SERIALIZATION_BUFFER_SIZE_BYTES_];
    size_t buf_size = uavcan_node_Heartbeat_1_0_SERIALIZATION_BUFFER_SIZE_BYTES_;

    uavcan_node_Heartbeat_1_0_serialize_(&heartbeat, buf, &buf_size);
    const SerardTransferMetadata metadata = {
      .priority = SerardPriorityNominal,
      .transfer_kind = SerardTransferKindMessage,
      .port_id = uavcan_node_Heartbeat_1_0_FIXED_PORT_ID_,
      .remote_node_id = NODE_ID,
    };
    serardTxPush(&serard, &metadata, buf_size, buf, nullptr, &serialEmitter);
  }
}

Cyphal requires that each node publish a heartbeat.

Serial Receiving

#include <uavcan/primitive/scalar/Bit_1_0.h>

// ...
static SerardReassembler reassembler;
// ...

static void onReceive(const SerardRxTransfer* const transfer)
{
  const SerardTransferMetadata* const metadata = &transfer->metadata;

  uavcan_primitive_scalar_Bit_1_0 bit;
  size_t size = transfer->payload_size;
  uavcan_primitive_scalar_Bit_1_0_deserialize_(&bit, reinterpret_cast<const uint8_t*>(transfer->payload), &size);
  serardFree(nullptr, transfer->payload_extent, transfer->payload);

  cmdCallback(bit);
}

static void cmdCallback(const uavcan_primitive_scalar_Bit_1_0& msg)
{
  if (msg.value)
  {
    digitalWrite(LED_BUILTIN, HIGH);
  }
  else
  {
    digitalWrite(LED_BUILTIN, LOW);
  }
}

void loop() {
  // ...
  // Handle incoming data
  uint8_t buf[256];
  SerardRxTransfer transfer;
  SerardRxSubscription* sub{nullptr};

  size_t payload_size = Serial.readBytes(reinterpret_cast<char*>(buf), 256);


  if (serardRxAccept(&serard, &reassembler, micros(), &payload_size, buf, &transfer, &sub)) {
    onReceive(&transfer);
  }
}

The reassembler is needed to hold the state of the deserialization since serial packets are arbitrary size.

The above sets up a node with heartbeat that can receive a command bit to turn on the LED

Command line tools

Monitor the node:

yakut --transport "Serial('COM7',None,baudrate=115200)" monitor

Send a command:

yakut --transport "Serial('COM7',None,baudrate=115200)" pub 1620:uavcan.primitive.scalar.Bit 'true' -N 2