Hardware Timestamping

It always takes time for information to travel between the hardware and the UI. This time difference is the latency of the connection. Connections always have latency, jitter and a degree of unreliability.

The UI operates at the whims of the scheduler of the host OS. Preemptive multitasking removes any guarantees of timing. Packets may spend inordinate amounts of time in buffers or in flight. Data sampled at a regular interval may appear, with this jitter, to be sampled seemingly at random.

Screenshot of component Retiming jittery

The hardware often has much better timing primitives available. It can record a timestamp and send it alongside the data, giving the UI much more precise information about when events happened.

This example demonstrates hardware timestamping and sharing a time basis between multiple messageIDs.

Firmware

First a struct is defined, TimeStampedData_t which contains both the uint16_t data as well as the uint32_t timestamp. The timestamp is the time in milliseconds since the microcontroller started up. It is monotonically increasing, and will overflow approximately every 49 days.

Every second, the data will be set to a random number between 0 and 5000, timestamped, and sent to the UI.

#include "electricui.h"
typedef struct
{
uint32_t timestamp;
uint16_t data;
} TimeStampedData_t;
TimeStampedData_t sensor_data;
char nickname[17] = "TimeStamped data";
eui_message_t tracked_vars[] =
{
EUI_CUSTOM_RO( "sens", sensor_data ),
EUI_CHAR_ARRAY_RO( "name", nickname ),
};
eui_interface_t serial_comms = EUI_INTERFACE( &serial_write );
uint32_t mock_data_ready = 0;
void setup()
{
Serial.begin(115200);
//eUI setup
eui_setup_interface( &serial_comms );
EUI_TRACK( tracked_vars );
eui_setup_identifier( "time", 5 );
}
void loop()
{
uart_rx_handler();
// Every second, emit a new packet of data
if( millis() - mock_data_ready >= 1000 )
{
// Set the timestamp and random data
sensor_data.timestamp = millis();
sensor_data.data = random(0, 5000);
// Send the packet, then reset the 'once a second' timer
eui_send_tracked("sens");
mock_data_ready = millis();
}
}
void uart_rx_handler()
{
while(Serial.available() > 0)
{
eui_parse( Serial.read(), &serial_comms );
}
}
void serial_write( uint8_t *data, uint16_t len )
{
Serial.write( data, len );
}

This example is written for Arduino compatibility, but key lessons: structure usage and timestamping is portable and intended to be modified to suit your use-case.

User interface

The codec will read the uint32_t timestamp, use a built in utility to exchange it for a UI timestamp, then read the uint16_t data.

The HardwareMessageRetimer is a utility which keeps track of the timestamp provided by hardware and the timers running in the UI.

The first packet received from hardware defines an offset, which is applied to all future packets.

The HardwareMessageRetimer can be instantiated with a limit of how much drift is allowed between the hardware time and the UI time before re-negotiating a time basis. By default this is 50ms.

// transport-manager/config/codecs.tsx
import { Codec, Message } from '@electricui/core'
import { HardwareMessageRetimer, HardwareTimeBasis } from '@electricui/protocol-binary-codecs'
import { SmartBuffer } from 'smart-buffer'
/**
* typedef struct
* {
* uint32_t timestamp;
* uint16_t data;
* } TimeStampedData_t;
*/
export interface TimeStampedData {
// We'll change the name to indicate that the origin will be based on the UI's time origin
offsetTimestamp: number,
data: number
}
export class TimeStampedUInt16Codec extends Codec<TimeStampedData> {
private retimer: HardwareMessageRetimer
constructor(timeBasis: HardwareTimeBasis) {
super()
this.retimer = new HardwareMessageRetimer(timeBasis)
}
filter(message: Message): boolean {
return message.messageID === 'sens'
}
encode(payload: TimeStampedData): Buffer {
throw new Error(`sens is readonly`)
}
decode(payload: Buffer): TimeStampedData {
const reader = SmartBuffer.fromBuffer(payload)
// Read out the timestamp from hardware
const hardwareOriginTimestamp = reader.readUInt32LE()
// Exchange the timestamp for one in the same time basis as the UI
const offsetTimestamp = this.retimer.exchange(hardwareOriginTimestamp)
// Read out the data
const data = reader.readUInt16LE()
return {
offsetTimestamp,
data,
}
}
}

The HardwareTimeBasis will be passed in during instantiation in the TransportFactory. The HardwareTimeBasis provides rollover information.

// transport-manager/config/serial.tsx
import { CodecDuplexPipelineWithDefaults, HardwareTimeBasis } from '@electricui/protocol-binary-codecs'
import { TimeStampedUInt16Codec } from './codecs'
// ...omitted for brevity
const serialTransportFactory = new TransportFactory((options: SerialTransportOptions) => {
const connectionInterface = new ConnectionInterface()
// ...omitted for brevity
const codecPipeline = new CodecDuplexPipelineWithDefaults()
// Create the HardwareTimeBasis, specifying a uint32_t container
const hardwareTimeBasis = new HardwareTimeBasis(32)
// Create the instances of the codecs
const customCodecs = [
new TimeStampedUInt16Codec(hardwareTimeBasis),
]
// Add custom codecs.
codecPipeline.addCodecs(customCodecs)
// ...omitted for brevity
return connectionInterface.finalise()
})

Multiple codecs can share a common HardwareTimeBasis. Imagine a TimeStampedXYZPositionCodec which takes a messageID as well as a HardwareTimeBasis.

// transport-manager/config/serial.tsx
import { CodecDuplexPipelineWithDefaults, HardwareTimeBasis } from '@electricui/protocol-binary-codecs'
import { TimeStampedUInt16Codec, TimeStampedXYZPositionCodec } from './codecs'
// ...omitted for brevity
const serialTransportFactory = new TransportFactory((options: SerialTransportOptions) => {
const connectionInterface = new ConnectionInterface()
// ...omitted for brevity
// Create the HardwareTimeBasis, specifying a uint32_t container
const hardwareTimeBasis = new HardwareTimeBasis(32)
// Create the instances of the codecs
const customCodecs = [
new TimeStampedUInt16Codec(hardwareTimeBasis),
new TimeStampedXYZPositionCodec('acc', hardwareTimeBasis),
new TimeStampedXYZPositionCodec('gyro', hardwareTimeBasis),
]
// ...omitted for brevity
return connectionInterface.finalise()
})

Each of these packets will have timestamps internally consistent with each other.

Using the timestamp

To actually use this timestamp, a custom processor is created which ingests both the offset timestamp and the data.

// transport-manager/index.tsx
import {
ElectronIPCRemoteQueryExecutor,
QueryableMessageIDProvider,
Event,
} from '@electricui/core-timeseries'
import { Message } from '@electricui/core'
import { TimeStampedData } from './config/codecs'
const remoteQueryExecutor = new ElectronIPCRemoteQueryExecutor()
const queryableMessageIDProvider = new QueryableMessageIDProvider(deviceManager, remoteQueryExecutor)
queryableMessageIDProvider.setCustomProcessor('sens', (message: Message<TimeStampedData>, emit) => {
if (!message.payload) {
// If there's no payload, do nothing
return
}
// Emit an event with the data
emit(new Event(message.payload.offsetTimestamp, message.payload.data))
})

The events are injested using the useMessageDataSource hook:

import {
ChartContainer,
LineChart,
RealTimeDomain,
TimeAxis,
VerticalAxis,
} from '@electricui/components-desktop-charts'
import { useMessageDataSource } from '@electricui/core-timeseries'
const Page = () => {
const sensorDataSource = useMessageDataSource('sens')
return (
<div style={{ textAlign: 'center', marginBottom: '1em' }}>
<b>Sensor</b>
</div>
<ChartContainer>
<LineChart dataSource={sensorDataSource} maxItems={10000} />
<RealTimeDomain window={10000} />
<TimeAxis />
<VerticalAxis />
</ChartContainer>
)
}

Despite any connection jitter, the hardware retimed packets are displayed at the correct time, to the precision of the hardware timer.

Screenshot of component Retiming non-jittery

Further reading