C++ with Nanopb#

pw_rpc can generate services which encode/decode RPC requests and responses as nanopb message structs.

Usage#

To enable nanopb code generation, the build argument dir_pw_third_party_nanopb must be set to point to a local nanopb installation. Nanopb 0.4 is recommended, but Nanopb 0.3 is also supported.

Define a pw_proto_library containing the .proto file defining your service (and optionally other related protos), then depend on the nanopb_rpc version of that library in the code implementing the service.

# chat/BUILD.gn

import("$dir_pw_build/target_types.gni")
import("$dir_pw_protobuf_compiler/proto.gni")

pw_proto_library("chat_protos") {
  sources = [ "chat_protos/chat_service.proto" ]
}

# Library that implements the Chat service.
pw_source_set("chat_service") {
  sources = [
    "chat_service.cc",
    "chat_service.h",
  ]
  public_deps = [ ":chat_protos.nanopb_rpc" ]
}

A C++ header file is generated for each input .proto file, with the .proto extension replaced by .rpc.pb.h. For example, given the input file chat_protos/chat_service.proto, the generated header file will be placed at the include path "chat_protos/chat_service.rpc.pb.h".

Generated code API#

All examples in this document use the following RPC service definition.

syntax = "proto3";

package chat;

service Chat {
  // Returns information about a chatroom.
  rpc GetRoomInformation(RoomInfoRequest) returns (RoomInfoResponse);

  // Lists all of the users in a chatroom.
  rpc ListUsersInRoom(ListUsersRequest) returns (stream ListUsersResponse);

  // Uploads a file, in chunks, to a chatroom.
  rpc UploadFile(stream UploadFileRequest) returns (UploadFileResponse);

  // Sends messages to a chatroom while receiving messages from other users.
  rpc SendMessage(stream ChatMessage) returns (stream ChatMessage);
}

message RoomInfoRequest {
  string room = 1;
}

message RoomInfoResponse {
  string room = 1;
  uint32 users = 2;
}

message ListUsersRequest {
  string room = 1;
}

message ListUsersResponse {
  string user = 1;
}

message UploadFileRequest {
  bytes chunk = 1;
}

message UploadFileResponse {
  uint32 bytes_received = 1;
}

message ChatMessage {
  string msg = 1;
  uint32 timestamp = 2;
}

Server-side#

A C++ class is generated for each service in the .proto file. The class is located within a special pw_rpc::nanopb sub-namespace of the file’s package.

The generated class is a base class which must be derived to implement the service’s methods. The base class is templated on the derived class.

class ChatService final
    : public ::chat::pw_rpc::nanopb::Chat::Service<ChatService> {
 public:
  // 1. Unary RPC
  pw::Status GetRoomInformation(const chat_RoomInfoRequest& request,
                                chat_RoomInfoResponse& response) {
    PW_LOG_INFO("Room requested: %s", request.room);
    std::strncpy(response.room, request.room, sizeof(response.room));
    response.users = 42;
    return pw::OkStatus();
  }

  // 2. Server Streaming RPC
  void ListUsersInRoom(
      const chat_ListUsersRequest& request,
      pw::rpc::NanopbServerWriter<chat_ListUsersResponse>& writer) {
    PW_LOG_INFO("Listing users in room: %s", request.room);
    chat_ListUsersResponse user1{.user = "Alice"};
    static_cast<void>(writer.Write(user1));
    chat_ListUsersResponse user2{.user = "Bob"};
    static_cast<void>(writer.Write(user2));
    static_cast<void>(writer.Finish(pw::OkStatus()));
  }

  // 3. Client Streaming RPC
  void UploadFile(
      pw::rpc::NanopbServerReader<chat_UploadFileRequest,
                                  chat_UploadFileResponse>& reader) {
    upload_reader_ = std::move(reader);

    upload_reader_.set_on_next([this](const chat_UploadFileRequest& request) {
      total_bytes_ += request.chunk.size;
      if (request.chunk.size == 0) {
        // Upload finished: complete the call with total bytes transferred
        chat_UploadFileResponse response{
            .bytes_received = static_cast<uint32_t>(total_bytes_)};
        static_cast<void>(upload_reader_.Finish(response, pw::OkStatus()));
      }
    });
  }

  // 4. Bidirectional Streaming RPC
  void SendMessage(
      pw::rpc::NanopbServerReaderWriter<chat_ChatMessage, chat_ChatMessage>&
          stream) {
    chat_stream_ = std::move(stream);

    chat_stream_.set_on_next([this](const chat_ChatMessage& message) {
      chat_ChatMessage reply{.msg = "Echo", .timestamp = message.timestamp};
      static_cast<void>(chat_stream_.Write(reply));
    });
  }

 private:
  pw::rpc::NanopbServerReader<chat_UploadFileRequest, chat_UploadFileResponse>
      upload_reader_;
  pw::rpc::NanopbServerReaderWriter<chat_ChatMessage, chat_ChatMessage>
      chat_stream_;
  size_t total_bytes_ = 0;
};

The writer and reader helper APIs provide methods to stream and finish calls:

Status NanopbServerWriter::Write(const Response &response)#

Writes a single response message to the stream. The returned status indicates whether the write was successful.

Status NanopbServerWriter::Finish(Status status = OkStatus())#

Closes the stream and sends back the RPC’s overall status to the client.

Status NanopbServerWriter::TryFinish(Status status = OkStatus())#

Closes the stream and sends back the RPC’s overall status to the client only if the final packet is successfully sent.

Attention

Make sure to use std::move when passing the NanopbServerWriter around to avoid accidentally closing it and ending the RPC.

Status NanopbServerReader::Finish(const Response &response, Status status = OkStatus())#

Sends the final unary response message and status to the client, closing the stream.

void NanopbServerReader::set_on_next(Function<void(const Request&)> &&on_next)#

Sets the callback invoked when a new request message arrives from the client.

Status NanopbServerReaderWriter::Write(const Response &response)#

Writes a single response message to the stream.

Status NanopbServerReaderWriter::Finish(Status status = OkStatus())#

Closes the stream and sends back the RPC’s overall status to the client.

void NanopbServerReaderWriter::set_on_next(Function<void(const Request&)> &&on_next)#

Sets the callback invoked when an incoming request message arrives from the client.

Client-side#

A corresponding client class is generated for every service defined in the proto file. To allow multiple types of clients to exist, it is placed under the pw_rpc::nanopb namespace. The Client class is nested under pw_rpc::nanopb::ServiceName. For example, the Chat service would create chat::pw_rpc::nanopb::Chat::Client.

Service clients are instantiated with a reference to the RPC client through which they will send requests, and the channel ID they will use.

Callback invocation

RPC callbacks are invoked synchronously from Client::ProcessPacket.

Unary RPC#

A unary RPC call takes the request struct and a callback to invoke when a response is received. The callback receives the RPC’s status and response struct.

Server streaming RPC#

A server streaming RPC call takes the initial request struct and two callbacks. The first is invoked on every stream response received, and the second is invoked once the stream is complete with its overall status.

Client streaming RPC#

A client streaming RPC call returns a NanopbClientWriter object used to send a stream of requests, and takes a callback invoked when the server’s final response arrives:

[[maybe_unused]] void StartUpload() {
  ChatClient chat_client(client, 1);

  auto writer = chat_client.UploadFile(
      [](const chat_UploadFileResponse& response, pw::Status status) {
        if (status.ok()) {
          PW_LOG_INFO("Uploaded %u bytes",
                      static_cast<unsigned>(response.bytes_received));
        }
      });

  chat_UploadFileRequest chunk{.chunk = {.size = 0, .bytes = {0}}};
  static_cast<void>(writer.Write(chunk));
  static_cast<void>(writer.RequestCompletion());
}

Bidirectional streaming RPC#

A bidirectional streaming RPC call returns a NanopbClientReaderWriter object used to send requests, and takes callbacks for incoming stream responses and stream completion:

[[maybe_unused]] void StartChat() {
  ChatClient chat_client(client, 1);

  auto stream = chat_client.SendMessage(
      [](const chat_ChatMessage& msg) {
        PW_LOG_INFO("Message from room: %s", msg.msg);
      },
      [](pw::Status status) { PW_LOG_INFO("Chat closed: %s", status.str()); });

  chat_ChatMessage msg{.msg = "Hello!", .timestamp = 0};
  static_cast<void>(stream.Write(msg));
  static_cast<void>(stream.RequestCompletion());
}
Example usage#

The following example demonstrates how to call an RPC method using a nanopb service client and receive the response.

using ChatClient = ::chat::pw_rpc::nanopb::Chat::Client;

void LogRoomInformation(const chat_RoomInfoResponse& response,
                        pw::Status status) {
  if (status.ok()) {
    PW_LOG_INFO("Room %s has %u users",
                response.room,
                static_cast<unsigned>(response.users));
  }
}

[[maybe_unused]] void InvokeSomeRpcs() {
  ChatClient chat_client(client, 1);

  // Unary call
  auto call =
      chat_client.GetRoomInformation({.room = "pigweed"}, LogRoomInformation);
  if (!call.active()) {
    return;
  }
}

Zephyr#

To enable pw_rpc.nanopb.* for Zephyr add CONFIG_PIGWEED_RPC_NANOPB=y to the project’s configuration. This will enable the Kconfig menu for the following:

  • pw_rpc.nanopb.method which can be enabled via CONFIG_PIGWEED_RPC_NANOPB_METHOD=y.

  • pw_rpc.nanopb.method_union which can be enabled via CONFIG_PIGWEED_RPC_NANOPB_METHOD_UNION=y.

  • pw_rpc.nanopb.client which can be enabled via CONFIG_PIGWEED_RPC_NANOPB_CLIENT=y.

  • pw_rpc.nanopb.common which can be enabled via CONFIG_PIGWEED_RPC_NANOPB_COMMON=y.

  • pw_rpc.nanopb.echo_service which can be enabled via CONFIG_PIGWEED_RPC_NANOPB_ECHO_SERVICE=y.