FastRPC is a both a library and a RPC protocol specification written in portable Nim code. The RPC protocol uses MessagePack with goals to be portable, relatively lightweight, and fast as possible. The code can run on Zephyr or FreeRTOS/LwIP while also capable and tested to run on Linux (not other *nixs currently).
Generally it's intended to support datagram transports, but an optional framing (see Notes below) mechanism can be used to work with stream based transports.
The FastRPC protocol is based on a variant of MessagePack-RPC however it has several incompatible changes partially to simplify implementation on microcontrollers.
The general protocol consists of Request
messages and corresponding Response
message(s). The server must send a Response
message in reply to a request unless the request type indicates that no response is expected.
The protocol and server implementation generally assume that each packet can fit into the physical media's MTU. On Ethernet this is generally 1500 bytes less the space used by the UDP (or TCP) packets. While WiFI 802.11 MTU is 2304 bytes with various overheads depending on the security settings. The smallest use case is probably CAN-FD which is 64 bytes (that's the large one). This can be configured.
The request message is a four element array as shown below, packed in MessagePack format.
[reqType, msgId, procName, params]
The fields are:
reqType: int8
request message type (current valid request values are5,7,9,19
)msgId: int32
sequence number for client to track (async) responsesprocName: string
name of the procedure (method) to callparams: array[MsgPackNodes]
an array of parameters of arbitrary MsgPack data.
The supported types of requests are:
Request = 5
Notify = 7
Subscribe = 9
SubscribeStop = 11
System = 19
Note: params
must be an array. Passing maps will return an error. The params in the array match the order of the arguments in a function call which is very fast to parse. Maps could be handled but would require handling out-of-order fields and strings which requires more overhead.
Response messages are a three element array as shown below, packed in MsgPack format.
[respType, msgId, result]
The fields are:
respType: int8
response message typemsgId: int32
sequence number for client to track responses, including async/streamsresult: MsgPackNodes
Arbitray packed MsgPack data
The supported types of responses are:
Response = 6
Error = 8
Publish = 10
PublishDone = 12
Note: the result
field is used to store errors when the response type is Error (8)
.
The client will send a Request
packet to the server. When UDP is used the server will respond on the source UDP IP & Port. Stream transports return to the same stream.
The server will process the RPC proc and return a Response
message, unless it encounters an error where the Error
response type will be sent. It's up to the client to handle these as desired.
The SystemRequest
is identical to Request
except that is supports server specific system calls. By default this includes methods like listall
that return all RPC methods handled by the server. API's like reboot should likely be a SystemRequest
. The server may want to require extra security tokens for this case.
TCP packets and UDP packets are treated slightly differently. TCP streams can be used to send/recv larger data than the underlying framesize but is discouraged. Prefer to use Publish
responses, which are valid to return from normal Request
.
To call add(int, int)
on a FastRPC server, you'd generate a Request
message:
const Request = 5
var callId = 1
var msg = (Request, callId, "add", (1,2))
assert msg == (5, 1, "add", (1,2))
This would then be serialized using a MsgPack library:
var msgbinary = msgpack4nim.pack(msg)
assert msgbinary == "\148\5\1\163add\146\1\2"
assert msgbinary == "\x94\x05\x01\xA3add\x92\x01\x02" # hex format
The response message would be:
assert responseMsg == "\147\6\1\3"
var response = msgpack4nim.unpack(responseMsg)
assert response == [6, 1, 3]
assert response == [Response, 1, 3]
let answer = 3
assert answer == response[2]
When using a stream transport you'd need to prefix the message length:
var tcpMsgBinary = msgbinary.len().toBeInt16() & msgbinary
assert msgbinary == "\0\10" & "\148\5\1\163add\146\1\2"
assert msgbinary == "\0\10\148\5\1\163add\146\1\2"
TODO: these function but need work finish describing the API and how to use them
Subscription semantics have been added using the Subscribe
and Publish
request types described above. These requests work differently than the regular Request
kinds. The request type needs to be Subscribe
with the RPC name and any arguements for starting a subscription stream. The return type isn't a normal RPC response but a response from the server that returns a subscription id
(SubId
in the code) if the subscription succeeded. TODO: describe the data format for the subscription id
.
Any responses from this subscription will use the subscription id
instead of the original msgid
. Multiple client's can subscribe to the same stream in this way. It also works well with multicast stream data.
In the future clients can send SubscribeStop
with RPC name and subscription id
as the data to stop the stream. The server can also send a PublishDone
response to indicate to the client that the stream is finished. This can be useful for long running RPC's that need to send lots of data back.
It would be possible to use this RPC Protocol with CBOR as the serialization protocol. Everything above would apply but instread using CBOR.
When using TCP, Unix domain sockets, serial, or other stream based connections a simple framing scheme is used to delineate messages. Each message must be prefixed by a bye-length in big-endian order. This is copied from Erlang port protocol for similar reasons.
Note: The default prefix length is 2-byte for messages up to ~65k in length, however, MCUs generally don't gracefully handle packets larger than transport frame size (e.g. ~1400 bytes on ethernet).
In theory, websocket framing could be used but isn't.
This method of framing isn't optimal for noisy data streams such as serial connections, though using timeouts and resets it'd be possible to retry RPC calls over it. In this case you may want to investigate something like SLIP that provides a more resiliant packeting mechanism.
The stream response (TCP) message would be:
var tcpResponseMsg = "\0\4\147\6\1\3"
var responseLen = rcpResponseMsg[0..1] # get byte size prefix
var responseMsg = rcpResponseMsg[2..^1] # slice off byte size prefix
assert responseMsg == "\147\6\1\3"
Note that reading from a stream with BSD sockets requires using an idiom like:
var pktLen = socket.read(2)
var msg = ""
while msg.len() < pktLen:
msg.add socket.read(pktLen - msg.len())