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.
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.tsximport { 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.tsximport { CodecDuplexPipelineWithDefaults, HardwareTimeBasis } from '@electricui/protocol-binary-codecs'import { TimeStampedUInt16Codec } from './codecs'// ...omitted for brevityconst 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.tsximport { CodecDuplexPipelineWithDefaults, HardwareTimeBasis } from '@electricui/protocol-binary-codecs'import { TimeStampedUInt16Codec, TimeStampedXYZPositionCodec } from './codecs'// ...omitted for brevityconst 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.tsximport { ElectronIPCRemoteQueryExecutor, QueryableMessageIDProvider, Event,} from '@electricui/core-timeseries'import { Message } from '@electricui/core'import { TimeStampedData } from './config/codecs'// ...omitted for brevityconst multiPersistenceEngine = new MultiPersistenceEngineMemory()const remoteQueryExecutor = new ElectronIPCRemoteQueryExecutor( multiPersistenceEngine,)const queryableMessageIDProvider = new QueryableMessageIDProvider( deviceManager, multiPersistenceEngine,)queryableMessageIDProvider.setCustomMessageProcessor( 'sens', (message: Message<TimeStampedData>, api) => { if (!message.payload) { // If there's no payload, do nothing return } // Emit an event with the data api.emit(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.