Recently I have been involved in a project that required a custom binary protocol specification be written for interaction between some custom hardware and a central processing server. Before
you wonder why re-invent the wheel I did look into protoBuf but although perfect for what I was doing, implementing this on the custom hardware would have been more difficult.
The communication was going to occur over TCP using sockets. Some of the requirements of this protocol included:
1. A small size protocol to minize network traffic
2. Libraries that could be used across multiple applications to handle the protocol
3. An extendable protocol so that new packet types could be included without altering the core layer
4. A flexible protocol to handle multiple scenarios such as variable string fields
5. The ability to implement backwards compability on packets
Based off these requirements we ended up producing a simple protocol specification that worked at the binary level. Each packet would be deliminated and contain data in the following format:
[stx][identifier][cmd][packet id][parent packet id][timestamp][data payload][crc][etx]
where
[stx] | $ (0x24) |
[identifier]: | byte 1 byte identifier, used for protocol version and other future purposes: 0x00: First protocol version |
[cmd]: | byte 1 byte command value |
[packet id]: | long Unique packet identifier as a 4-byte long |
[parent packet id]: | long The original packet id being responded to |
[timestamp]: | long The Unix timestamp (Epoch) as a 4-byte long since 1 Jan 1970. |
[payload]: | 1..110 bytes (prior to byte stuffing method outlined below). |
[crc]: | char 1 byte checksum value calculated on |
[etx]: | * (0x2A) |
where definitions include
char | 1 byte (Interpreted as a ASCII character) |
byte | 1 byte |
int | 2 bytes |
Long | 4 bytes |
String | UTF-8 encoded, variable length |
Float | 4 bytes |
Character stuffed | Byte stuffing equivalent |
---|---|
$ (0x24) | ^% (0x5E 0x25) |
* (0x2A) | ^+ (0x5E 0x2B) |
^ (0x5E) | ^_ (0x5E 0x5F) |
Byte stuffing is all maintained within two simple classes that enable us to byte stuff and unstuff any data we wish.
var unStuffedData = new byte[] { some bytes }; var stuffer = new BinaryPacketStuffer(ignoreStxEtx: true); var stuffedData = stuffer.Stuff(unStuffedData);
In some cases the data we are stuffing contains a $ and * in which case we want to ignore those two properties. The boolean
parameter ignoreStxEtx allows us to do just that if desired.
Data payloads
Data payloads form the core extensibility of the protocol and is where you can define any type of data specification you desire. An example of a simple payload that would take at minimum 6 bytes and a variable amount of bytes read in as a string.
public class LogRequest : DataPayload { public int ErrorCode { get; set; } public byte Length { get; set; } public string Text { get; set; } public override void Write(IDataOutputStream outputStream) { outputStream.Write((short)ErrorCode); outputStream.Write(Length); outputStream.Write(Text, Text.Length); } public override void Read(IDataInputStream inputStream) { ErrorCode = inputStream.ReadInt16(); Length = inputStream.ReadByte(); Text = inputStream.ReadString(); } }
Versioning
Each packet version is identified in the header payload. Because of this we can implemet specific readers for our payloads so that we can handle old versions of a packet as well as new versions without having to worry and contain nasty switch statements throughout our application.
This is implemented in our Payload specific implementation by overriding the CreateVersionReader() method. An example is as follows
protected override IPacketReader CreateVersionReader(int version) { switch (version) { case 0: return base.CreateVersionReader(version); default: return new AnalogInputChangedReader(this); // by default use yhe latest known reader } }
Creating packets using PacketBuilder
There is a built in class that can easily create packets and allow you to serialize to and from byte[]. This class is called the PacketBuilder and you would use it as such:
Converting to a byte array
var packetBuilder = new PacketBuilder(); var expectedPacket = packetBuilder.CreateEmptyPayload(PacketCommands.AlertRequest); expectedPacket.Data.TypeOfAlert = AlertRequest.AlertTypes.MainBatteryLost; expectedPacket.Data.Value = 100; var bytes = packetBuilder.Build(expectedPacket);
Converting from a byte array
var rawData = new byte[] { 0x24,0x01,0x61,0xAB,0x0C,0x04,0x00,0x00,0x00,0x00, 0x00,0x4D,0x34,0x51,0x56,0x04,0x08,0x0F,0xE8,0x32, 0x00,0x08,0x22,0xE5,0x30,0x00,0x08,0x32,0xD2,0x33, 0x00,0x08,0x34,0xC0,0x31,0x00,0x8D,0x2A }; var packetBuilder = new PacketBuilder(); var analogPacket = packetBuilder.Build(rawData);
Code review
Some discussion of my original thoughts around this protocol can be found at:
http://codereview.stackexchange.com/questions/44625/writing-and-reading-of-a-custom-binary-protocol
Known limitations
1. The amount of commands is limited to what can fit into a single byte
2. The checksum is very small and there is currently no way to insert a custom implementation
Some future enhancements
1. I'm considering exending the ability to use attributes and so not need to implement Read and Write for every payload if you decorate your properties with the necessary attributes. Currently this is loosly implemented
but does require some testing (28-March-2016).
2. It would be great to allow custom checksum algorithms and those algorithms to specify the checksum length.
3. Increasing the command field from one byte
Nuget packages
- Symtech.G5.Protocol
Summary
This library has worked well and has produced a solid foundation for sending and receiving data sent as binary. It is easily extendable with new types of Payloads making it flexible as a protocol
library in and of itself. An existing protocol is found as a nuget package under Symtech.G5.Protocol.V2.