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 Nanopb (Recommended)#
Declare the service class:
class SensorServiceImpl final
: public pw::rpc::examples::pw_rpc::nanopb::SensorService::Service<
SensorServiceImpl> {
public:
// 1a. Synchronous Unary RPC
pw::Status GetReading(const pw_rpc_examples_SensorRequest& request,
pw_rpc_examples_SensorResponse& response);
// 1b. Asynchronous Unary RPC (Alternative)
void GetReadingAsync(
const pw_rpc_examples_SensorRequest& request,
pw::rpc::NanopbUnaryResponder<pw_rpc_examples_SensorResponse>&
new_responder);
// 2. Server Streaming RPC
void StreamReadings(
const pw_rpc_examples_StreamRequest& request,
pw::rpc::NanopbServerWriter<pw_rpc_examples_SensorResponse>& writer);
// 3. Bidirectional Streaming RPC
void Calibrate(
pw::rpc::NanopbServerReaderWriter<pw_rpc_examples_CalibrationPoint,
pw_rpc_examples_CalibrationStatus>&
stream);
private:
pw::rpc::NanopbUnaryResponder<pw_rpc_examples_SensorResponse>
async_responder_;
pw::rpc::NanopbServerReaderWriter<pw_rpc_examples_CalibrationPoint,
pw_rpc_examples_CalibrationStatus>
calib_stream_;
};
Implement the RPC methods:
pw::Status SensorServiceImpl::GetReading(
const pw_rpc_examples_SensorRequest& /*request*/,
pw_rpc_examples_SensorResponse& response) {
response.temperature = 24.2f;
response.humidity = 45.0f;
response.error = false;
return pw::OkStatus();
}
void SensorServiceImpl::GetReadingAsync(
const pw_rpc_examples_SensorRequest& /*request*/,
pw::rpc::NanopbUnaryResponder<pw_rpc_examples_SensorResponse>&
new_responder) {
// Move responder to member to complete asynchronously later
async_responder_ = std::move(new_responder);
}
void SensorServiceImpl::StreamReadings(
const pw_rpc_examples_StreamRequest& request,
pw::rpc::NanopbServerWriter<pw_rpc_examples_SensorResponse>& writer) {
for (uint32_t i = 0; i < request.sample_count; ++i) {
pw_rpc_examples_SensorResponse response{
.temperature = 20.0f + static_cast<float>(i),
.humidity = 50.0f,
.error = false,
};
if (!writer.Write(response).ok()) {
break; // Client disconnected or buffer full
}
}
static_cast<void>(writer.Finish(pw::OkStatus()));
}
void SensorServiceImpl::Calibrate(
pw::rpc::NanopbServerReaderWriter<pw_rpc_examples_CalibrationPoint,
pw_rpc_examples_CalibrationStatus>&
stream) {
calib_stream_ = std::move(stream);
calib_stream_.set_on_next(
[this](const pw_rpc_examples_CalibrationPoint& point) {
pw_rpc_examples_CalibrationStatus status{
.calibrated = true,
.offset = point.measured_val - point.reference_val,
};
static_cast<void>(calib_stream_.Write(status));
});
}
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:
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.
Zero-copy serialization: Write fields directly into the wire buffer in-place using
pw::protobuf::StreamEncoder.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_protobuf |
|
Raw |
|
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);
}