Skip to main content

Parse Messages

This tutorial describes the process of parsing device messages from a byte array using C# and .NET.

Common Interface

An EMessageType enumeration is defined to represent the supported message types. Each message structure implements the IMessageBase interface, which provides a common infrastructure for message parsing and handling.

public enum EMessageType : int
{
IMU = 0x24,
GPS = 0x25,
CAN = 0x26,
SessionStart = 0x80,
PacketRtcTime = 0x81
}

public interface IMessageBase
{
EMessageType Type { get; }
}

Message Type Implementations

A dedicated read-only structure is defined for each message type. Each structure is responsible for parsing its corresponding raw message data.

SessionStart

The parser validates the message type and message length, then extracts the Timestamp and StartTime fields from the raw data.

public readonly struct SessionStart : IMessageBase
{
private const int MESSAGE_LENGTH = 8;
private const int PACKET_TIME_OFFSET = 4;

public EMessageType Type => EMessageType.SessionStart;

public TimeSpan Timestamp { get; init; }

public DateTime StartTime { get; init; }

public static SessionStart Parse(ref DeviceMessage m)
{
if (m.Type != (int)EMessageType.SessionStart)
{
throw new Exception("Message type not match for SessionStart");
}

if (m.Length != MESSAGE_LENGTH)
{
throw new Exception("Message length not match for SessionStart");
}

return new SessionStart()
{
Timestamp = TimeSpan.FromMicroseconds(
BinaryPrimitives.ReadUInt32BigEndian(m.Data.Span) * DeviceMessageParser.TIMESTAMP_FACTOR),
StartTime = DateTime.UnixEpoch.AddSeconds(
BinaryPrimitives.ReadUInt32BigEndian(m.Data.Span[PACKET_TIME_OFFSET..]))
};
}
}

PacketRtcTime

The parser validates the message type and message length, then extracts the PacketTime field from the raw data.

public readonly struct PacketRtcTime : IMessageBase
{
private const int MESSAGE_LENGTH = 4;

public EMessageType Type => EMessageType.PacketRtcTime;

public DateTime PacketTime { get; init; }

public static PacketRtcTime Parse(ref DeviceMessage m)
{
if (m.Type != (int)EMessageType.PacketRtcTime)
{
throw new Exception("Message type not match for PacketRtcTime");
}

if (m.Length != MESSAGE_LENGTH)
{
throw new Exception("Message length not match for PacketRtcTime");
}

return new PacketRtcTime()
{
PacketTime = DateTime.UnixEpoch.AddSeconds(
BinaryPrimitives.ReadUInt32BigEndian(m.Data.Span))
};
}
}

ImuSensor

This message type is more complex due to the number of contained fields. A Vector structure is defined to represent vector data for the X, Y, and Z axes. A SensorData structure is used to store both raw and filtered sensor values.

The ImuSensor structure implements the message format for IMU sensor data. The parser uses BinaryPrimitives to read Motorola (big-endian) byte-ordered fields. Scaling factors and offsets are applied to specific fields as required.

public readonly struct Vector
{
public double X { get; init; }

public double Y { get; init; }

public double Z { get; init; }

public override string ToString()
{
return $"[{X:f02};{Y:f02};{Z:f02}]";
}
}

public readonly struct SensorData
{
public Vector Raw { get; init; }

public Vector Filtered { get; init; }
}

public readonly struct ImuSensor : IMessageBase
{
private const int MESSAGE_LENGTH = 30;

private const int ACC_X_OFFSET = 4;
private const int ACC_Y_OFFSET = 6;
private const int ACC_Z_OFFSET = 8;
private const int GYRO_X_OFFSET = 10;
private const int GYRO_Y_OFFSET = 12;
private const int GYRO_Z_OFFSET = 14;
private const int TEMP_FIELD_OFFSET = 16;
private const int ACC_XF_OFFSET = 18;
private const int ACC_YF_OFFSET = 20;
private const int ACC_ZF_OFFSET = 22;
private const int GYRO_XF_OFFSET = 24;
private const int GYRO_YF_OFFSET = 26;
private const int GYRO_ZF_OFFSET = 28;

private const double ACC_RAW_FACTOR = 0.244 * 9.80665 / 1000.0;
private const double ACC_FILTERED_FACTOR = 9.80665 / 3000.0;
private const double GYRO_RAW_FACTOR = 17.5 / 1000.0;
private const double GYRO_FILTERED_FACTOR = 1.0 / 50.0;
private const double TEMP_FACTOR = 1.0 / 256.0;
private const double TEMP_OFFSET = 25;

public EMessageType Type => EMessageType.IMU;

public TimeSpan Timestamp { get; init; }

public SensorData AccelerationSensor { get; init; }

public SensorData GyroSensor { get; init; }

public double InternalTemperature { get; init; }

public static ImuSensor Parse(ref DeviceMessage m)
{
if (m.Type != (int)EMessageType.IMU)
{
throw new Exception("Message type not match for Imu");
}

if (m.Length != MESSAGE_LENGTH)
{
throw new Exception("Message length not match for Imu");
}

return new ImuSensor()
{
Timestamp = TimeSpan.FromMicroseconds(
BinaryPrimitives.ReadUInt32BigEndian(m.Data.Span) * DeviceMessageParser.TIMESTAMP_FACTOR),
AccelerationSensor = new SensorData()
{
Raw = new Vector()
{
X = BinaryPrimitives.ReadInt16BigEndian(m.Data.Span[ACC_X_OFFSET..]) * ACC_RAW_FACTOR,
Y = BinaryPrimitives.ReadInt16BigEndian(m.Data.Span[ACC_Y_OFFSET..]) * ACC_RAW_FACTOR,
Z = BinaryPrimitives.ReadInt16BigEndian(m.Data.Span[ACC_Z_OFFSET..]) * ACC_RAW_FACTOR
},
Filtered = new Vector()
{
X = BinaryPrimitives.ReadInt16BigEndian(m.Data.Span[ACC_XF_OFFSET..]) * ACC_FILTERED_FACTOR,
Y = BinaryPrimitives.ReadInt16BigEndian(m.Data.Span[ACC_YF_OFFSET..]) * ACC_FILTERED_FACTOR,
Z = BinaryPrimitives.ReadInt16BigEndian(m.Data.Span[ACC_ZF_OFFSET..]) * ACC_FILTERED_FACTOR
}
},
GyroSensor = new SensorData()
{
Raw = new Vector()
{
X = BinaryPrimitives.ReadInt16BigEndian(m.Data.Span[GYRO_X_OFFSET..]) * GYRO_RAW_FACTOR,
Y = BinaryPrimitives.ReadInt16BigEndian(m.Data.Span[GYRO_Y_OFFSET..]) * GYRO_RAW_FACTOR,
Z = BinaryPrimitives.ReadInt16BigEndian(m.Data.Span[GYRO_Z_OFFSET..]) * GYRO_RAW_FACTOR
},
Filtered = new Vector()
{
X = BinaryPrimitives.ReadInt16BigEndian(m.Data.Span[GYRO_XF_OFFSET..]) * GYRO_FILTERED_FACTOR,
Y = BinaryPrimitives.ReadInt16BigEndian(m.Data.Span[GYRO_YF_OFFSET..]) * GYRO_FILTERED_FACTOR,
Z = BinaryPrimitives.ReadInt16BigEndian(m.Data.Span[GYRO_ZF_OFFSET..]) * GYRO_FILTERED_FACTOR
}
},
InternalTemperature = (BinaryPrimitives.ReadInt16BigEndian(m.Data.Span[TEMP_FIELD_OFFSET..]) * TEMP_FACTOR) + TEMP_OFFSET
};
}
}

GpsSensor

The most complex aspect of parsing GPS sensor data is handling the latitude and longitude fields, which are encoded in degrees and minutes. These fields are represented as 4-byte integers and must be divided by the GNSS_GPS_LAT_DIV and GNSS_GPS_LON_DIV constants, respectively.

For example, a value of 472757563 becomes 4727.57563 after division, which represents 47° 27.57563’. This value is then converted to decimal degrees, resulting in 47.45959383°.

public readonly struct GpsSensor : IMessageBase
{
private const int MESSAGE_LENGTH = 28;

private const int GPS_TIME_OFFSET = 4;
private const int LATITUDE_OFFSET = 12;
private const int LONGITUDE_OFFSET = 16;
private const int HDOP_OFFSET = 20;
private const int ALTITUDE_OFFSET = 22;
private const int SPEED_OFFSET = 26;

private const double GNSS_GPS_LAT_DIV = 100000.0;
private const double GNSS_GPS_LON_DIV = 100000.0;
private const float GNSS_GPS_HDP_DIV = 100.0f;
private const float GNSS_GPS_ALT_DIV = 100.0f;
private const float GNSS_GPS_SPD_DIV = 100.0f;

public EMessageType Type => EMessageType.GPS;

public TimeSpan Timestamp { get; init; }

public DateTime GpsTime { get; init; }

public double Latitude { get; init; }

public double Longitude { get; init; }

public float Hdop { get; init; }

public float Altitude { get; init; }

public float Speed { get; init; }

public static GpsSensor Parse(ref DeviceMessage m)
{
if (m.Type != (int)EMessageType.GPS)
{
throw new Exception("Message type not match for GPS");
}

if (m.Length != MESSAGE_LENGTH)
{
throw new Exception("Message length not match for GPS");
}

double lat = BinaryPrimitives.ReadUInt32BigEndian(m.Data.Span[LATITUDE_OFFSET..]) / GNSS_GPS_LAT_DIV;
double lon = BinaryPrimitives.ReadUInt32BigEndian(m.Data.Span[LONGITUDE_OFFSET..]) / GNSS_GPS_LON_DIV;

return new GpsSensor()
{
Timestamp = TimeSpan.FromMicroseconds(
BinaryPrimitives.ReadUInt32BigEndian(m.Data.Span) * DeviceMessageParser.TIMESTAMP_FACTOR),
GpsTime = DateTime.UnixEpoch.AddMilliseconds(
BinaryPrimitives.ReadUInt64BigEndian(m.Data.Span[GPS_TIME_OFFSET..])),
Latitude = Math.Truncate(lat / 100.0) + ((lat / 100.0) - Math.Truncate(lat / 100.0)) / 0.6,
Longitude = Math.Truncate(lon / 100.0) + ((lon / 100.0) - Math.Truncate(lon / 100.0)) / 0.6,
Hdop = BinaryPrimitives.ReadUInt16BigEndian(m.Data.Span[HDOP_OFFSET..]) / GNSS_GPS_HDP_DIV,
Altitude = BinaryPrimitives.ReadUInt32BigEndian(m.Data.Span[ALTITUDE_OFFSET..]) / GNSS_GPS_ALT_DIV,
Speed = BinaryPrimitives.ReadUInt16BigEndian(m.Data.Span[SPEED_OFFSET..]) / GNSS_GPS_SPD_DIV
};
}
}

CanBusMessage

CAN bus messages are variable-length messages. Each message frame includes a length field for the entire message, as well as a Data Length Code (DLC) field that specifies the number of data bytes contained in the CAN message. Depending on the DLC value, the total length of the CAN bus message may vary.

public readonly struct CanBusMessage(byte[] data) : IMessageBase
{
private const int FLAGS_OFFSET = 4;
private const int CAN_ID_OFFSET = 5;
private const int CAN_DATA_OFFSET = 9;

private readonly byte[] _data = data;

public EMessageType Type => EMessageType.CAN;

public TimeSpan Timestamp { get; init; }

public int Channel { get; init; }

public bool IsExtended { get; init; }

public uint CanId { get; init; }

public int Dlc { get; init; }

public ReadOnlyMemory<byte> Data => _data;

public static CanBusMessage Parse(ref DeviceMessage m)
{
if (m.Type != (int)EMessageType.CAN)
{
throw new Exception("Message type not match for CAN");
}

byte flags = m.Data.Span[FLAGS_OFFSET];
int channel = (flags >> 4) & 0x0F;
int dlc = flags & 0x0F;
uint id = BinaryPrimitives.ReadUInt32BigEndian(m.Data.Span[CAN_ID_OFFSET..]);
bool isXtd = (id & 0x80000000) != 0;
uint canId = id & 0x1FFFFFFF;

if ((CAN_DATA_OFFSET + dlc) > m.Length)
{
throw new Exception("Message length not match for CAN");
}

return new CanBusMessage(m.Data.Slice(CAN_DATA_OFFSET, dlc).ToArray())
{
Timestamp = TimeSpan.FromMicroseconds(
BinaryPrimitives.ReadUInt32BigEndian(m.Data.Span) * DeviceMessageParser.TIMESTAMP_FACTOR),
Channel = channel,
IsExtended = isXtd,
CanId = canId,
Dlc = dlc
};
}
}

Device Message Parser

A general message parser can be implemented by composing the individual message parsers. For performance optimization, the order of message type handling in the switch statement is arranged according to the frequency of occurrence of each message type. The most frequent message type is CAN bus messages, while the least frequent is SessionStart.

public class DeviceMessageParser
{
internal const long TIMESTAMP_FACTOR = 50;

public static IMessageBase? Parse(DeviceMessage m)
{
return (int)m.Type switch
{
(int)EMessageType.CAN => CanBusMessage.Parse(ref m),
(int)EMessageType.IMU => ImuSensor.Parse(ref m),
(int)EMessageType.GPS => GpsSensor.Parse(ref m),
(int)EMessageType.PacketRtcTime => PacketRtcTime.Parse(ref m),
(int)EMessageType.SessionStart => SessionStart.Parse(ref m),
_ => null,// Unknown message type
};
}
}

The parser can then be used as illustrated below:

// Processing vehicle data frames
foreach (DeviceMessage message in frame)
{
// Handle messages
IMessageBase? deviceMessage = DeviceMessageParser.Parse(message);

if (deviceMessage is CanBusMessage can)
{
_vm.AddCanBusMessage(_lastRtcTime, can);
}
else if (deviceMessage is ImuSensor imu)
{
_vm.Imu.Update(_lastRtcTime, imu);
}
else if (deviceMessage is GpsSensor gps)
{
_vm.Gps.Update(_lastRtcTime, gps);
}
else if (deviceMessage is PacketRtcTime rtcTime)
{
_lastRtcTime = rtcTime.PacketTime;
}
}