Creating services#

pw_rpc: Efficient, low-code-size RPC system for embedded devices

This guide walks through defining, implementing, and testing a new RPC service in C++ using pw_rpc.

If you are a platform engineer looking to set up the RPC server, channels, and transports for your project, start with the Integration & setup instead.

1. Define the service (.proto)#

Define your RPC service and message types in a Protocol Buffer file using proto3 syntax.

syntax = "proto3";

package pw.rpc.examples;

service SensorService {
  // A synchronous or asynchronous unary RPC
  rpc GetReading(SensorRequest) returns (SensorResponse);

  // A server streaming RPC (server returns multiple responses)
  rpc StreamReadings(StreamRequest) returns (stream SensorResponse);

  // A bidirectional streaming RPC
  rpc Calibrate(stream CalibrationPoint) returns (stream CalibrationStatus);
}

message SensorRequest {
  uint32 sensor_id = 1;
}

message SensorResponse {
  float temperature = 1;
  float humidity = 2;
  bool error = 3;
}

message StreamRequest {
  uint32 sensor_id = 1;
  uint32 sample_count = 2;
  uint32 interval_ms = 3;
}

message CalibrationPoint {
  float reference_val = 1;
  float measured_val = 2;
}

message CalibrationStatus {
  bool calibrated = 1;
  float offset = 2;
}

proto2 versus proto3 syntax#

Always use proto3 syntax rather than proto2 for new protocol buffers. proto2 protobufs can be compiled for pw_rpc, but pw_rpc lacks support for non-zero default values in proto2.

If you need to distinguish between a default-valued field and a missing field, mark the field as optional in proto3:

syntax = "proto3";

message ConfigMessage {
  // Leaving this field unset is equivalent to setting it to 0.
  uint32 sample_rate = 1;

  // Setting this field to 0 is distinguishable from leaving it unset.
  optional uint32 timeout_ms = 2;
}

2. Configure your build system#

pw_rpc automatically generates C++ service base classes from your .proto files.

Bazel#

Use nanopb_rpc_proto_library or pwpb_rpc_proto_library:

proto_library(
    name = "sensor_proto",
    srcs = ["sensor_service.proto"],
    deps = [
        "//pw_protobuf:common_proto",
    ],
)

nanopb_proto_library(
    name = "sensor_nanopb",
    deps = [":sensor_proto"],
)

nanopb_rpc_proto_library(
    name = "sensor_nanopb_rpc",
    nanopb_proto_library_deps = [":sensor_nanopb"],
    deps = [":sensor_proto"],
)

raw_rpc_proto_library(
    name = "sensor_raw_rpc",
    deps = [":sensor_proto"],
)

py_proto_library(
    name = "sensor_proto_pb2",
    deps = [":sensor_proto"],
)

pw_py_test(
    name = "host_client_test",
    srcs = ["host_client.py"],
    main = "host_client.py",
    deps = [
        ":sensor_proto_pb2",
        "//pw_hdlc/py:pw_hdlc",
        "//pw_rpc/py:pw_rpc",
        "//pw_status/py:pw_status",
    ],
)

cc_library(
    name = "sensor_service",
    srcs = ["sensor_service.cc"],
    hdrs = ["sensor_service.h"],
    deps = [
        ":sensor_nanopb_rpc",
        "//pw_bytes",
        "//pw_rpc",
        "//pw_rpc/nanopb:server_api",
        "//pw_rpc/raw:server_api",
        "//pw_status",
    ],
)

pw_cc_test(
    name = "sensor_service_test",
    srcs = ["sensor_service_test.cc"],
    deps = [
        ":sensor_service",
        "//pw_rpc/nanopb:test_method_context",
        "//pw_unit_test",
    ],
)

GN#

In a BUILD.gn file, use the pw_proto_library template:

import("$dir_pw_protobuf_compiler/proto.gni")

pw_proto_library("sensor_protos") {
  sources = [ "sensor_service.proto" ]
}

pw_source_set("sensor_service") {
  sources = [ "sensor_service.cc" ]
  deps = [
    ":sensor_protos.nanopb_rpc",  # For Nanopb
    # or :sensor_protos.pwpb_rpc  # For pw_protobuf
    # or :sensor_protos.raw_rpc   # For Raw RPC
  ]
}

CMake#

In a CMakeLists.txt file, use the pw_proto_library function:

include($ENV{PW_ROOT}/pw_build/pigweed.cmake)
include($ENV{PW_ROOT}/pw_protobuf_compiler/proto.cmake)

pw_proto_library(sensor_protos
  SOURCES
    sensor_service.proto
)

add_library(sensor_service_impl ...)
target_link_libraries(sensor_service_impl PUBLIC
  sensor_protos.nanopb_rpc
)

3. Implement the service class in C++#

Inherit from your generated service base class and implement the RPC methods.

Using pw_protobuf#

Include the generated header "my_project/sensor_service.rpc.pwpb.h":

#include "my_project/sensor_service.rpc.pwpb.h"

class SensorServicePwpbImpl final
    : public my_project::pw_rpc::pwpb::SensorService::Service<
          SensorServicePwpbImpl> {
 public:
  pw::Status GetReading(const my_project::SensorRequest::Message& request,
                        my_project::SensorResponse::Message& response) {
    response.temperature = 22.0f;
    response.humidity = 40.0f;
    return pw::OkStatus();
  }
};

Falling back to raw methods#

You can mix raw RPC methods inside a Nanopb or pw_protobuf service! This is useful when:

  1. Handling repeated fields or callbacks: Nanopb callbacks require functions to be set before decoding; raw RPC gives you raw bytes so you can decode manually.

  2. Zero-copy serialization: Write fields directly into the wire buffer in-place using pw::protobuf::StreamEncoder.

  3. Low-overhead loopback / echo benchmarking.

To use raw methods, change the method signature to use pw::ConstByteSpan and pw::rpc::RawServerWriter / pw::rpc::RawUnaryResponder:

class MyMixedService final
    : public pw::rpc::examples::pw_rpc::nanopb::SensorService::Service<
          MyMixedService> {
 public:
  // Standard Nanopb unary method:
  pw::Status GetReading(const pw_rpc_examples_SensorRequest& /*request*/,
                        pw_rpc_examples_SensorResponse& /*response*/) {
    return pw::OkStatus();
  }

  // Raw server streaming method fallback:
  void StreamReadings(pw::ConstByteSpan /*request_bytes*/,
                      pw::rpc::RawServerWriter& writer) {
    std::byte payload[32]{};
    static_cast<void>(writer.Write(payload));
    static_cast<void>(writer.Finish(pw::OkStatus()));
  }
};

4. Register the service with the RPC server#

Instantiate your service implementation and register it with the RPC server:

SensorServiceImpl sensor_service;

void RegisterAppServices(pw::rpc::Server& server) {
  server.RegisterService(sensor_service);
}

Unrequested responses#

pw_rpc supports sending server streaming responses to RPCs that have not yet been invoked by a client. This is useful in scenarios like a device reboot: after rebooting, the device opens the writer object and streams status to the host.

// Open a ServerWriter for a server streaming RPC
auto writer = RawServerWriter::Open<pw_rpc::raw::ServiceName::MethodName>(
    server, channel_id, service_instance);

// Send responses
writer.Write(encoded_response_1);
writer.Write(encoded_response_2);

// Finish the stream
writer.Finish(pw::OkStatus());

5. Unit testing the service#

pw_rpc provides test method contexts that manage the RPC lifecycle, capture response packets, and allow simulating client requests without needing a physical transport.

Protobuf Library

Test Method Context

Nanopb

PW_NANOPB_TEST_METHOD_CONTEXT

pw_protobuf

PW_PWPB_TEST_METHOD_CONTEXT

Raw

PW_RAW_TEST_METHOD_CONTEXT

Unary RPC test example#

TEST(SensorServiceTest, GetReading_ReturnsValidData) {
  PW_NANOPB_TEST_METHOD_CONTEXT(SensorServiceImpl, GetReading) context;

  pw_rpc_examples_SensorRequest request{.sensor_id = 1};
  EXPECT_EQ(pw::OkStatus(), context.call(request));

  EXPECT_TRUE(context.done());
  EXPECT_FLOAT_EQ(24.2f, context.response().temperature);
}

Streaming RPC test example#

TEST(SensorServiceTest, StreamReadings_StreamsMultipleResponses) {
  PW_NANOPB_TEST_METHOD_CONTEXT(SensorServiceImpl, StreamReadings) context;

  pw_rpc_examples_StreamRequest request{
      .sensor_id = 1,
      .sample_count = 3,
      .interval_ms = 10,
  };
  context.call(request);

  EXPECT_TRUE(context.done());
  ASSERT_EQ(3u, context.responses().size());
  EXPECT_FLOAT_EQ(20.0f, context.responses()[0].temperature);
}