Project Origins#
This is the first devlog in the development story of WindRPC (Micro Interconnect & Network Dispatch), a lightweight Remote Procedure Call (RPC) framework designed for communication between microcontrollers and host applications.
https://github.com/micro-artwork/windrpc
Background#
While developing the Bitnari project (an ambient light controller), I was evaluating various communication protocols for inter-device messaging.
My goal was to apply Protocol Buffers (Protobuf) for structured binary data serialization between controller hardware and PC applications.
While I preferred an RPC-style function execution model over raw message passing, standard gRPC requires heavy HTTP/2 infrastructure, making it impossible to run directly on resource-constrained microcontrollers (MCUs) using NanoPB.
Initially, I considered a simple compromise: using Protobuf strictly as a data message payload format without an RPC abstraction.
Initial Concept & GATT Inspiration#
However, I still wanted an RPC-like mechanism rather than simple message passing. Driven by technical curiosity, I decided to design a lightweight RPC system inspired by the Bluetooth Low Energy (BLE) GATT architecture.
BLE GATT organizes communication around Profiles, Services, and Characteristics, assigning operational properties (Read, Write, Write Without Response, Notify, Indicate) to characteristics.
While attempting to adapt this concept, deeper analysis revealed that the BLE GATT model is not well-suited for true bidirectional RPC:
- Attribute-Centric Limitation: BLE characteristics are designed primarily for reading/writing device state variables or emitting unidirectional events.
- Difficulty in Transaction Mapping: It lacks a native pattern for mapping request packets to response packets asynchronously using transaction sequence IDs.
Rather than adopting BLE’s attribute model directly, I borrowed its core ideas: hierarchical organization (Services) and operation property classification.
Hierarchical Structure#
- Replace data variables under Services with explicit Procedure definitions.
Procedure Property Classification#
- Request-Response: Standard bidirectional RPC where the client sends a request and waits for a server response.
- One-Way (Write Without Response): Fast command transmission without waiting for confirmation (e.g., high-speed LED pixel streaming).
- Asynchronous Notification (Notify): Server-initiated data push when sensor values change or device events occur, without explicit client polling.
The Missing RPC Descriptor#
After establishing the operation types, I encountered another major hurdle: the lack of a lightweight RPC specification descriptor format.
- Standard
.protogRPC Syntax Unusable: Standard.protofiles useserviceandrpckeywords tailored specifically for gRPC, which cannot be parsed by NanoPB in embedded environments. Reusing gRPC syntax with a custom parser risked causing syntax confusion. - Custom IDL Cost: Designing a brand-new Interface Definition Language (IDL) and parser from scratch required prohibitive maintenance overhead.
YAML-Based RPC Descriptor (user_spec.yml)#
To solve this, I adopted YAML as the specification descriptor format. YAML is human-readable, easy to edit, and trivial to parse into Python objects.
Instead of writing .proto files, C headers, and C# classes manually, a single user_spec.yml file serves as the source of truth for the entire interface.
Separation of Core Spec and User Spec#
A key architectural design decision was strictly separating the Core Spec from the User Spec.
If framework-level system services (such as Ping and Device Info) were mixed with application-specific business logic (such as Wi-Fi settings or LED streaming), framework updates could cause RPC ID collisions or maintenance headaches.
Therefore, WindRPC divides specifications into two distinct scopes:
- Core Spec: Essential framework-level services. Service IDs 1 through 6 are reserved for system RPCs (e.g., Ping, Version Handshake) and shared data types/enums.
- User Spec: Application-specific logic defined by developers. Developers use Service IDs 7 and above, defining custom logic inside
user_spec.yml.
This separation guarantees backward compatibility and framework independence while allowing developers to focus solely on their application specification.
user_spec.yml Specification Rules#
- Metadata: Define project name (
project) and spec version (version). - Enums: Define status codes and constant enumerations.
- Structs: Define message structures with field types (
string,uint32,bytes) and static memory constraints (max_length,max_count). - Services & RPCs:
- Service ID: Unique 16-bit namespace identifier (User range: 7+).
- RPC ID: Unique function identifier within a service.
- Mapping: Assign
request,response, oreventmessage targets.
# Example user_spec.yml structure
package: bitnari
info:
title: "Bitnari Control Specification"
version: "1.0.0"
services:
- id: 7
name: led_control
messages:
- name: PixelColor
fields:
- { number: 1, name: r, type: uint32 }
- { number: 2, name: g, type: uint32 }
- { number: 3, name: b, type: uint32 }
- name: PixelData
fields:
- number: 1
name: colors
type: PixelColor
property: repeated
nanopb: { max_count: 64 }
rpcs:
- id: 1
name: display_pixels
type: REQUEST_ONLY
request: PixelData
- id: 8
name: power_manager
messages:
- name: PowerStatus
fields:
- { number: 1, name: voltage_mv, type: uint32 }
- { number: 2, name: is_charging, type: bool }
rpcs:
- id: 1
name: get_power_status
type: REQUEST_RESPONSE
request: types.Empty
response: PowerStatus
- id: 2
name: charging_alert
type: NOTIFICATION
event: PowerStatus
Verification & Proto Generation#
With the descriptor format established, I wrote the parser generator to output standard .proto files from user_spec.yml.
Initially, the primary goal was verifying that valid .proto files were consistently generated from the YAML descriptor.
Using the generated .proto files, I compiled NanoPB C code for MCU and C# classes for .NET, verifying end-to-end binary packet serialization and deserialization across host and embedded environments.
Confirming error-free binary exchange between C MCU and C# .NET validated the descriptor-driven code generation architecture.
Automatic Nanopb .options Generation#
Alongside .proto files, the generator script was built to automatically emit Nanopb .options files (e.g., user_service.options).
This was essential due to the memory constraints of MCU C environments:
- Zero-Heap Static Memory: Standard Protobuf
stringorrepeatedfields require dynamic memory allocation (malloc/free) orpb_callback_tdecoding callbacks in C. In RAM-restricted MCUs, dynamic allocation risks memory fragmentation and runtime crashes. - Automatic Extraction of
nanopb:Bounds: By declaring static bounds in YAML (nanopb: { max_size: 64, max_count: 32 }), the generator emits matching.optionsrules automatically. - Static C Struct Generation: The Nanopb compiler (
protoc) uses the.optionsfile to replace dynamic pointers and field callbacks with fixed static C arrays (e.g.,char buffer[64];,uint32_t colors[32];).
As a result, developers obtain Zero-Heap static C structures directly from YAML definitions without writing manual .options files or C headers.
Conclusion#
What started as a desire to use Protocol Buffers in the Bitnari project evolved into designing a custom RPC framework.
In the next post, I will discuss the early nested message structure and C server code generation.
