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);
}

Introspecting services and methods (QueryService)#

pw_rpc provides an optional introspection service, Query (defined in pw_rpc/query.proto and implemented by pw::rpc::QueryService), that allows clients to query a running RPC server for its registered services and supported methods.

Note

This interface is intended for debug and development purposes and should not be relied upon in production.

The service provides four RPC methods:

  • ListServices: Streams all registered 32-bit service IDs.

  • ListMethods: Streams all 32-bit method IDs supported by a service.

  • IsServiceAvailable: Checks whether a specific service ID is registered.

  • IsMethodAvailable: Checks whether a specific method ID is supported by a service.

Server setup (C++)#

Instantiate QueryService and initialize it with your pw::rpc::Server:

#include <array>

#include "pw_rpc/channel.h"
#include "pw_rpc/query_service_pwpb.h"
#include "pw_rpc/server.h"

namespace pw::rpc::examples {
namespace {

std::array<pw::rpc::Channel, 1> channels;
pw::rpc::Server server(channels);
pw::rpc::QueryService query_service;

[[maybe_unused]] void Init() {
  // Init registers the QueryService with the server and enables introspection.
  query_service.Init(server);
}

}  // namespace
}  // namespace pw::rpc::examples

In pw_system, QueryService is included and can be enabled by setting the configuration option PW_SYSTEM_ENABLE_RPC_QUERY_SERVICE to 1 in your build configuration.