Library for JSON-RPC-like communication between arduino and Micro-manager device drivers.
Uses ArduinoCore-host for mocking Arduino client-server interactions.
Simplified JSON-RPC scheme designed for fast lookup/access on microcontrollers. Similar to the JSON-RPC 2.0 specification but with the following changes
- NO
{"jsonrpc": "2.0"}
tag at the beginning of the message - NO named parameters, only positional parameters
- "m" short for "method"
- "p" short for "params"
- "i" short for "id"
- "r" short for "result"
- errors are not returned as a {code,message} named tuples,
just the numeric code.
- instead of "error": {"code": -32600, "message": "..."}
- error returned as "e": -32600
- remote methods with void returns just the message id
- Uses ArduinoJson for JSON processing.
- Compiler switch allows a quick switch to a more compact MessagePack binary serial format.
- SlipInPlace SLIP+NULL encoding allowing binary formats over string-like streams.
- Unique terminator character at the end of each message
- No
\0
(null) characters anywhere in the encoded message, so messages can be treated like standard C-strings.
RPC call with positional parameters
--> {"m": "subtract", "p": [42, 23], "i": 1}
<-- {"r": 19, "i": 1}
RPC call with 'void' return [SET]
--> {"m": "setfoo", "p": [42], "i": 2}
<-- {"i": 2}
RPC call with return and no parameters [GET]
--> {"m": "getfoo", "i": 3}
<-- {"r": 42, "i": 3}
RPC notification (no id means no reply requested)
--> {"m": "update", "p": [1,2,3,4,5]}
--> {"m": "foobar"}
RPC call with error return
--> {"m": "subtract", "p": [42], "i": 3}
<-- {"e": -32600, "i": 3}
RPC set-notify/get get pair [SETN-GET]
--> {"m": "setfoo", "p": [3.1999]}
--> {"m": "gettfoo", "i": 4}
<-- {"r": 3.2, "i": 4}
The library contains a set of Arduino/C++11 compatible generic function delegates and stubs. There is a separate set of delegates and stubs designed for dispatching on ArduinoJson documents.
Delegates are based on "The Impossibly Fast C++ Delegates" by Sergey A Kryukov.
WARNING: Stubs and delegates do not use smart_pointers and have no destructors. They do not check if an object or function has gone out-of-context. Use them for permanent (top-level) or semi-permanent objects and functions that last the lifetime of the program. If you need function delegates with delete, move, etc, use std::function found in the STL header
Remote properties are layered on top of this compact JSON-RPC protocol. This scheme is designed specifically for micro-manager Device Properties.
Property get/set/sequencing rely on JsonDelegate method names with special prepended codes. The remote property name should be a short, unique character sequence like "prop". We will call this abbreviation of the property name the property brief
.
We prepend a single-character opcode to the property brief to denote a standard property operation. The server is responsible for dispatching the coded brief to the appropriate function. A possible mechanism is detailed below, but dispatch tables are flexible and can use pure callback functions, class methods, or lambda with capture.
NOTE: codes are prepended rather than appended to make string matching terminate earlier during dispatch map method string lookup.
brief
method tags should be kept to a few characters for the same reason. Even a fast hash-map will need to loop through the entire string to compute the hash-value for lookup. So use a brief method tag like "dacv" rather than "MyDACOutputValueInVolts".
opcode | operation | meth1 | server signature2 |
---|---|---|---|
? | GET value | get | call<T,EX...>("?brief",ex...)->T |
! | SET value | set | call<void,T,EX...>("!brief",t,ex...) |
! | NSET value - no reply | set | notify<void,T,EX...>("!brief",t,ex...) |
* | ACT task | act | call<void,EX...>("*brief",ex...) |
* | NOTIFY task (ACT without response) | act | notify<void,EX...>("*brief",ex...) |
-- | SEQUENCE/ARRAY COMMANDS | -- | -- |
^ | GET maximum size of seq array3 | array | call<long,EX...>("^brief",ex...)->long |
# | GET number of values in seq array | array | call<long,EX...>("#brief",ex...)->long |
0 | CLEAR seq array | array | notify<long,EX...>("0brief",ex...)->dummy |
+ | ADD value to sequence array | set | notify<void,T,EX...>("+brief",ex...) |
* | ACT task doubles as start seq. | act | call<void,EX...>("*brief",ex...) |
* | NOTIFY task to start seq. | act | notify<void,EX...>("*brief",ex...) |
~ | STOP sequence | act | call<void,EX...>("\~brief",ex...) |
~ | STOP sequence | act | notify<void,EX...>("\~brief",ex...) |
From the signature table above, we need four local method signatures for transforming MM Properties into eventual RPC calls on the server. The client method might also transform the MM::PropertyType into the type T required by the server. Each method type includes an optional set of compile-time extra parameters such as channel number, pin number, etc. What the server does with this information depends on the method opcode.
- get: gets the remote property value
- set: sets the remote property value
- act (action): performs some task associated with the property and check status. Can be called or notified.
- array: (array actions) gets either the current or maximum array size or clears the array
The normal 'SET' call method doesn't return the value actually set on the remote device - just an OK (returns caller id) or error number. If we want to verify and retrive the value that was actually set to the device We can use a NSET then GET. A normal SET-GET RPC pair would need to wait for two replies, one from the SET call, one from the GET call. Instead we can use NSET (notify-SET, i.e. no reply) followed immediately by a GET call.
A volatile remote property can change behind-the-scenes. We cannot rely on a cached value and the remote property might not preserve the exact value of a SET operation. Volatile properties must:
- Always Use NSET-GET pairs when setting
- Always use GET and never use cached values.
For sequence arrays, the client can send a stream of array notifications Use 0prop
to clear then a string of +prop
, +prop
, ... to add values to the array in order. Array setting can be speedy because the client doesn't need to wait for replies to each array optimizatoin. Instead, the client can check on the progress every, say, 20th value with a #prop
GET call. The results should match the number of array values sent so far. A final #prop
GET call at the end can verify the array was properly filled. The number of consecutive values to send safely will depend on the size of the transmission buffer and the MessagePack'd size of each json notification call.
Clients should first send a ^prop
GET call to query the maximum array size on the remote device.
Lambda methods in the server's dispatch map can make the process of routing opcodes simpler. The server can hard-code each coded method call with a series of key/lambda function pairs.
For example in pseudo-code, a property with a value and possible sequence might be coded as lambda captures (pseudocode) (see add_to
in src/ServerProperty.h for the full version):
map<String,function> dispatch_map {
{"?prop", call<int>( [&prop_]()->int { return prop_; })},
{"!prop", call<void,int>([&prop_](int val) { prop_ = val; })},
{"^prop", call<int>( [&pseq_max_]()->int { return pseq_max_; })},
{"#prop", call<int>( [&pseq_count_]()->int { return pseq_count_; })},
{"0prop", call<int>( [&pseq_count_]()->int { pseq_count_ = 0; return 0; })},
{"+prop", call<void,int>([&pseq_,&acount_](int val) { pseq_[pseq_count_++] = val; })}
};
Future version of remote dispatch might incorporate auto-decoding dispatch for sequencable/array properties at the server level.
See src/ServerProperty.h for an implementation of sequencable simple properties and channel properties. Derive simple_prop_base
or channel_prop_base
and override virtual get, set, start, stop methods to perform hardware operations. Call the base class versions to cache the current value.
Properties may have extra call parameters for routing the command to the appropriate place. For example, a short channel
parameter might be used to route a property to the appropriate DAC channel or a pin
parameter could indicate a digital I/O pin to set.
The MM driver client is responsible for populating these extra values during the call
dispatch command and the server is responsible for decoding the extra parameter and taking the appropriate action. The client/server RPC dispatch mechanism has variadic template arguments for precisely this reason!
Channel index: Channel commands use one extra integer parameter to designate the channel
index (zero based). Individual channels are querried using their channel index.
All channels: We use a special channel index < 0 to designate an operation on all channels. To get the maximum number of channels, call with the maximum sequence size code (^
) and a channel index of -1. Start and Stop sequence calls can use a channel index of -1 to start all channels simultaneosly.
Some properties need different types for the client and server. For example, the client MM device might want to set analog ouput as a floating-point number, but the remote device DAC only takes 16-bit integers. Or the client device uses state
strings but the remote device expects numeric enum
state values.
A transforming property allows the client device to add a lambda function (with possible [&] captures) to transform the property from the client type to the required server type on-the-fly before sending the remote property.
An Arduino-compatible (C++11) Serial Line Internet Protocol (SLIP) encoder and decoder.
SlipInPlace can perform both in-place and out-of-place encoding and decoding of SLIP packets. This is useful for fixed size buffer.
The normal SLIP protocol encodes a packet of bytes with unique END
code at the end of a stream and a guarantee that the END
code will not appear anywhere else in the packet. The standard SLIP code table is below.
Char Code | Octal | Hex | encoded as |
---|---|---|---|
END | \300 |
0xC0 |
\333\334 , 0xDBDC |
ESC | \333 |
0xDB |
\333\335 , 0xDBDD |
EscapedEND | \334 |
0xDC |
never alone |
EscapedESC | \335 |
0xDD |
never alone |
Before adding the END
code to the end of the stream, SLIP searches for any existing characters matching END
and replaces them with a two-character ESC-EscapedEND
codelet. It also has to guarantee that there are no spurious ESC
codes in the original data. So it also searches for ESC
codes in the original data and replaces them with a two-character ESC-EscapedESC
codelet.
The protocol then adds an END
code at the end of the packet that serial handlers can search for as the 'terminator' character in an incoming stream. The terminator (END
) is guaranteed only to appear at the end of a packet.
Decoding works in reverse by shrinking the packet and replacing the escaped codes by their non-escaped originals.
This library has another SLIP+NULL encoder that replaces END
and ESC
as above, but also searches for and replaces any NULL
characters (\0
) in the middle of the packet with a two-byte ESC-EscapedNULL
sequence. That way we can guarantee no NULL
bytes in the encoded packet. Serial communication handlers 4 that only deal with C-strings (ending in \0
) can safely handle data containing the byte zero after the packet has been encoded to eliminate any internal zeros.
Char Code | Octal | Hex | encoded as |
---|---|---|---|
NULL | \000 |
0x00 |
\333\336 , 0xDBDE |
EscapedNULL | \336 |
0xDE |
never alone |
The standard SLIP encoder and decoder are pre-defined. Two simple use-cases are below
Out-of-place encoding and decoding:
char ebuf[16];
const char* source = "Lo\300rus"; // Note END in middle of string
// encoding
size_t esize = slip::encoder::encode(ebuf, 16, source, strlen(source));
// ebuf == "Lo\333\334rus\300"; esize == 8;
// decoding
char dbuf[16];
size_t dsize = slip::decoder::decode(dbuf, 16, ebuf, esize);
// dbuf == "Lo\300rus"; dsize == 6;
string final(dbuf, dsize);
// final == "Lo\300rus";
In-place encoding and decoding:
char buffer[16];
const char* source = "Lo\300rus"; // Note END in middle of string
strcpy(buffer, source);
// encoding
size_t esize = slip::encoder::encode(buffer, 16, buffer, strlen(source));
// buffer == "Lo\333\334rus\300"; esize == 8;
// decoding
size_t dsize = slip::decoder::decode(buffer, 16, buffer, esize);
// buffer == "Lo\300rus"; dsize == 6;
string final(buffer, dsize);
// final == "Lo\300rus";
Communication protocols are usually byte oriented rather than character oriented. In C and C++ char
can also encode UTF-8 strings with two-byte characters. The default SLIP encoder/decoder pairs work with unsigned chars
(uint8_t
) and includes additional encode()
and decode()
functions that translate char*
as unsigned char*
via reinterpret_cast<>
.
You can declare a char encoder or decoder that works with chars
(uint8_t
) through slip_decoder_base
and slip_encoder_base
//=== With 'typedef' directives
typedef slip::encoder_base<char> char_encoder;
typedef slip::decoder_base<char> char_decoder;
//=== OR with 'using' directives
using encoder = slip_encoder_base<char>;
using decoder = slip_decoder_base<char>;
Codes are defined at compile time using template parameters. For example, \test\hrslip.h
defines a human-readable SLIP coded used for almost all algorithm and unit testing.
typedef slip::encoder_base<char,'#','^','D','[','0','@'> encoder_hrnull;
typedef slip::decoder_base<char,'#','^','D','[','0','@'> decoder_hrnull;
The resulting codelets (in template order)
Char Code | Char | encoded as |
---|---|---|
END | # |
^D |
ESC | ^ |
^[ |
EscapedEND | D |
never alone |
EscapedESC | [ |
never alone |
NULL | 0 |
^@ |
EscapedNULL | @ |
never alone |
So for example
const size_t bsize = 10;
char buffer[32];
char* source = "Lo^#rus";
// encoding
size_t esize = encoder_hrnull::encode(buffer,32,source,strlen(source));
//> ebuf == "Lo^[^Drus#"; esize == 10;
// Original ESC^ is now ^[, END# is now ^D and END# only at terminus
// decoding
size_t dsize = decoder_hrnull::decode(buffer,32,buffer,esize);
//> dbuf == "Lo^#rus"; dsize == 7;
The \examples\sip_encode
folder contains a similar human-readable encoding that prints encoded and decoded streams on the Arduino's serial port.
(You can get a glimpse of how in-place vs out-of-place encoding works by looking at the diagnostic buffer outputs.)
The encoding and decoding libraries have unit tests of various scenarios. See the \tests
directory for Unit tests.
See \examples
for Arduino sample sketches.
Footnotes
-
meth is the client method whose parameters match the call/notify signature ↩
-
Signature of the server method. T is the property type on the device, EX... are an optional set of extra parameters such as channel number ↩
-
Micro-manager makes several calls to GET maximum sequence size. Maximum sequence size is checked only once and the value is cached by the device driver. ↩
-
I'm looking at you, MicroManager device driver
GetSerialAnswer()
. ↩