@foxglove/ulog
Version:
PX4 ULog file reader
529 lines (455 loc) • 17.3 kB
text/typescript
import { ChunkedReader } from "./ChunkedReader";
import {
Field,
MessageDefinition,
parseFieldDefinition,
parseMessageDefinition,
} from "./definition";
import { MessageType } from "./enums";
import { Filelike } from "./file";
import { findRange } from "./findRange";
import { toHex } from "./hex";
import {
MessageFlagBits,
MessageAddLogged,
DataSectionMessage,
FieldPrimitive,
MessageDataParsed,
} from "./messages";
import { fieldSize, parseBasicFieldValue, parseMessage } from "./parse";
import {
readMessageAddLogged,
readMessageData,
readMessageHeader,
readMessageLog,
readMessageLogTagged,
readRawMessage,
} from "./readMessage";
export type ParameterEntry = { value: number; defaultTypes: number };
export type ULogHeader = {
version: number;
timestamp: bigint; // [μs]
flagBits?: MessageFlagBits;
information: Map<string, FieldPrimitive | FieldPrimitive[]>;
parameters: Map<string, ParameterEntry>;
definitions: Map<string, MessageDefinition>;
};
export type Subscription = MessageDefinition & Pick<MessageAddLogged, "multiId">;
const MAGIC = [0x55, 0x4c, 0x6f, 0x67, 0x01, 0x12, 0x35];
type MsgId = number & { __brand: "MsgId" };
/**
* The synthetic message id for log messages.
*
* When entering a log message in an IndexEntry we tag it with this message id so we can identify
* log messages in the readMessage function if the `includeLogs` option is true.
*/
const LogMessageId = -1 as MsgId;
/**
* An entry in the time index pointing to a message in the ulog file
*
* timestamp: the timestamp of the message
* offset: byte location in the file
* msgId: the message id if the message is a data message, LogMessageId if it is a log message, or undefined if it is not a data message
*/
type IndexEntry = [timestamp: bigint, offset: number, msgId: MsgId | undefined];
export class ULog {
#filelike: Filelike;
#chunkSize: number | undefined;
// These members are only populated after open()
#dataEnd?: number;
#header?: ULogHeader;
#appendedOffsets?: [number, number, number];
#subscriptions = new Map<number, Subscription>();
#timeIndex?: IndexEntry[];
#dataMessageCounts?: Map<number, number>;
#logMessageCount?: number;
#dataTimeRange?: [bigint, bigint];
constructor(filelike: Filelike, opts: { chunkSize?: number } = {}) {
this.#filelike = filelike;
this.#chunkSize = opts.chunkSize;
}
get header(): ULogHeader | undefined {
return this.#header;
}
/**
* Return a map of message ids to their corresponding subscription
*/
get subscriptions(): Map<number, Subscription> {
return this.#subscriptions;
}
async open(): Promise<void> {
await this.#filelike.open();
const reader = new ChunkedReader(this.#filelike, this.#chunkSize);
const data = await reader.readBytes(8);
for (let i = 0; i < MAGIC.length; i++) {
if (data[i] !== MAGIC[i]) {
throw new Error(`Invalid ULog header: ${toHex(data)}`);
}
}
const version = data[7]!;
const timestamp = await reader.readUint64();
const information = new Map<string, FieldPrimitive | FieldPrimitive[]>();
const parameters = new Map<string, ParameterEntry>();
const definitions = new Map<string, MessageDefinition>();
let flagBits: MessageFlagBits | undefined;
while (!(await isDataSectionStart(reader))) {
const message = await readRawMessage(reader, this.#dataEnd);
if (!message) {
break;
}
switch (message.type) {
case MessageType.FlagBits: {
flagBits = message;
break;
}
case MessageType.Information: {
const infoMsg = message;
const field = parseFieldDefinition(infoMsg.key);
if (isValidInfoField(field)) {
const value = infoMsg.value;
const view = new DataView(value.buffer, value.byteOffset, value.byteLength);
const parsed = parseBasicFieldValue(field, view);
information.set(field.name, parsed);
}
break;
}
case MessageType.InformationMulti: {
const infoMultiMsg = message;
const field = parseFieldDefinition(infoMultiMsg.key);
if (isValidInfoField(field)) {
let array = information.get(infoMultiMsg.key) as FieldPrimitive[] | undefined;
if (!Array.isArray(array)) {
array = [];
information.set(infoMultiMsg.key, array);
}
const value = infoMultiMsg.value;
const view = new DataView(value.buffer, value.byteOffset, value.byteLength);
const parsed = parseBasicFieldValue(field, view);
array.push(parsed);
}
break;
}
case MessageType.FormatDefinition: {
const formatMsg = message;
const msgdef = parseMessageDefinition(formatMsg.format);
if (msgdef) {
definitions.set(msgdef.name, msgdef);
} else {
throw new Error(`oops: ${formatMsg.format}`);
}
break;
}
case MessageType.Parameter: {
const paramMsg = message;
const field = parseFieldDefinition(paramMsg.key);
if (isValidParameter(field)) {
const value = paramMsg.value;
const view = new DataView(value.buffer, value.byteOffset, value.byteLength);
const parsed = parseBasicFieldValue(field, view);
parameters.set(field.name, { value: parsed as number, defaultTypes: 0 });
}
break;
}
case MessageType.ParameterDefault: {
const paramMsg = message;
const field = parseFieldDefinition(paramMsg.key);
if (isValidParameter(field)) {
const value = paramMsg.value;
const view = new DataView(value.buffer, value.byteOffset, value.byteLength);
const parsed = parseBasicFieldValue(field, view) as number | undefined;
if (parsed != undefined) {
parameters.set(field.name, { value: parsed, defaultTypes: paramMsg.defaultTypes });
}
}
break;
}
case MessageType.Unknown:
case MessageType.AddLogged:
case MessageType.RemoveLogged:
case MessageType.Data:
case MessageType.Log:
case MessageType.LogTagged:
case MessageType.Synchronization:
case MessageType.Dropout:
default:
throw new Error(`Unrecognized message type ${message.type}`);
}
}
// File offsets are stored as 64-bit unsigned integers, but we cast to Number here which safely
// stores up to 53-bit integers. This supports ulogs up to 8192 TB in length
const appendedOffsets = flagBits?.appendedOffsets ?? [0n, 0n, 0n];
this.#appendedOffsets = appendedOffsets.map((n) => Number(n)) as [number, number, number];
const firstOffset = this.#appendedOffsets[0];
this.#dataEnd = reader.size();
if (firstOffset > 0 && firstOffset < this.#dataEnd) {
this.#dataEnd = firstOffset;
}
this.#header = { version, timestamp, flagBits, information, parameters, definitions };
await this.#createIndex(reader);
}
async *readMessages(
opts: {
startTime?: bigint;
endTime?: bigint;
/** If true (default) logs messages are yielded from the time range. */
includeLogs?: boolean;
/** If specified, only messages with the given message ids are yielded. */
msgIds?: Set<number>;
/** If true, the messages are yielded in reverse order (default false). */
reverse?: boolean;
} = {},
): AsyncIterableIterator<DataSectionMessage> {
const includeLogs = opts.includeLogs ?? true;
const msgIds = opts.msgIds;
const reader = new ChunkedReader(this.#filelike, this.#chunkSize);
const timeIndex = this.#timeIndex;
if (timeIndex == undefined) {
throw new Error(`Cannot readMessages before createIndex`);
}
if (timeIndex.length === 0) {
return;
}
const startTime = opts.startTime ?? timeIndex[0]![0];
const endTime = opts.endTime ?? timeIndex[timeIndex.length - 1]![0];
const range = findRange(timeIndex, startTime, endTime);
if (range == undefined) {
return;
}
const startIndex = range[0];
const endIndex = range[1];
// The number of records between the start and end index. We will iterate this many times
// reading records. Which record we start from and the increment depends on the reverse flag.
const diffIndex = endIndex - startIndex;
// When reading in reverse we will start reading at endIndex and decrement towards startIndex
const increment = opts.reverse === true ? -1 : 1;
let idx = opts.reverse === true ? endIndex : startIndex;
for (let i = 0; i <= diffIndex; i++, idx += increment) {
const [_timestamp, offset, msgId] = timeIndex[idx]!;
if (includeLogs && msgId === LogMessageId) {
reader.seekTo(offset);
const msg = await this.#readParsedMessage(reader);
if (msg) {
yield msg;
continue;
}
}
// If there are message ids specified, then only yield if the locator msgId matches
if (msgIds != undefined) {
if (msgId == undefined || !msgIds.has(msgId)) {
continue;
}
}
reader.seekTo(offset);
const msg = await this.#readParsedMessage(reader);
if (msg) {
yield msg;
}
}
}
messageCount(): number | undefined {
return this.#timeIndex?.length;
}
dataMessageCounts(): ReadonlyMap<number, number> | undefined {
return this.#dataMessageCounts;
}
logCount(): number | undefined {
return this.#logMessageCount;
}
timeRange(): Readonly<[bigint, bigint]> | undefined {
return this.#dataTimeRange;
}
async #createIndex(reader: ChunkedReader): Promise<void> {
if (!this.#header || this.#dataEnd == undefined) {
throw new Error(`Cannot read before open`);
}
const dataEnd = this.#dataEnd;
const timeIndex: IndexEntry[] = [];
const dataCounts = new Map<number, number>();
let minTimestamp: bigint | undefined;
let maxTimestamp = 0n;
let logMessageCount = 0;
// The offset of the timestamp field for a data message. The offset is from the start of the
// message data.
const timestampFieldOffsets = new Map<MsgId, number>();
for (;;) {
const offset = reader.position();
// If there is less than 3 bytes left in the file, we can't read a message header so we're done
if (dataEnd - offset < 3) {
break;
}
const header = await readMessageHeader(reader);
const type = header.type as MessageType;
// If there's not enough bytes in the file to read the message then we end indexing
if (reader.position() + header.size > dataEnd) {
break;
}
if (type === MessageType.AddLogged) {
// read the AddLogged message from the reader data
const addLogged = await readMessageAddLogged(reader, header);
this.#handleSubscription(addLogged);
timeIndex.push([maxTimestamp, offset, undefined]);
} else if (type === MessageType.Log) {
const logMsg = await readMessageLog(reader, header);
if (minTimestamp == undefined || logMsg.timestamp < minTimestamp) {
minTimestamp = logMsg.timestamp;
}
if (logMsg.timestamp > maxTimestamp) {
maxTimestamp = logMsg.timestamp;
}
timeIndex.push([logMsg.timestamp, offset, LogMessageId]);
logMessageCount++;
} else if (type === MessageType.LogTagged) {
const logMsg = await readMessageLogTagged(reader, header);
if (minTimestamp == undefined || logMsg.timestamp < minTimestamp) {
minTimestamp = logMsg.timestamp;
}
if (logMsg.timestamp > maxTimestamp) {
maxTimestamp = logMsg.timestamp;
}
timeIndex.push([logMsg.timestamp, offset, LogMessageId]);
logMessageCount++;
} else if (type === MessageType.Data) {
const dataMsg = await readMessageData(reader, header);
let timestampOffset = timestampFieldOffsets.get(dataMsg.msgId as MsgId);
if (timestampOffset == undefined) {
// We don't yet have a timestamp offset for this message id so we compute it
const definition = this.#subscriptions.get(dataMsg.msgId);
if (!definition) {
const msgPos = reader.position() - header.size - 3;
throw new Error(
`Unknown msg_id ${dataMsg.msgId} for ${header.size} byte 'D' message at offset ${msgPos}`,
);
}
timestampOffset = computeTimetampOffset(definition, this.#header.definitions);
timestampFieldOffsets.set(dataMsg.msgId as MsgId, timestampOffset);
}
const view = new DataView(
dataMsg.data.buffer,
dataMsg.data.byteOffset,
dataMsg.data.byteLength,
);
// we know the timestamp offset so we can parse the timestamp
const timestamp = view.getBigUint64(timestampOffset, true);
if (minTimestamp == undefined || timestamp < minTimestamp) {
minTimestamp = timestamp;
}
if (timestamp > maxTimestamp) {
maxTimestamp = timestamp;
}
timeIndex.push([timestamp, offset, dataMsg.msgId as MsgId]);
dataCounts.set(dataMsg.msgId, (dataCounts.get(dataMsg.msgId) ?? 0) + 1);
} else {
timeIndex.push([maxTimestamp, offset, undefined]);
// Skip past this message
reader.seek(header.size);
}
}
this.#timeIndex = timeIndex.sort(sortTimeIndex);
this.#dataMessageCounts = dataCounts;
this.#logMessageCount = logMessageCount;
this.#dataTimeRange = minTimestamp != undefined ? [minTimestamp, maxTimestamp] : undefined;
}
async #readParsedMessage(reader: ChunkedReader): Promise<DataSectionMessage | undefined> {
if (!this.#header) {
throw new Error(`Cannot read before open`);
}
const rawMessage = await readRawMessage(reader, this.#dataEnd);
if (!rawMessage) {
return undefined;
}
if (rawMessage.type !== MessageType.Data) {
return rawMessage as DataSectionMessage;
}
const dataMsg = rawMessage;
const definition = this.#subscriptions.get(dataMsg.msgId);
if (!definition) {
const msgPos = reader.position() - rawMessage.size - 3;
throw new Error(
`Unknown msg_id ${dataMsg.msgId} for ${rawMessage.size} byte 'D' message at offset ${msgPos}`,
);
}
const data = dataMsg.data;
const value = parseMessage(
definition,
this.#header.definitions,
reader.view()!,
data.byteOffset,
);
const parsed: MessageDataParsed = {
size: dataMsg.size,
type: MessageType.Data,
msgId: dataMsg.msgId,
data,
value,
};
return parsed;
}
#handleSubscription(subscribe: MessageAddLogged): void {
const definition = this.#header?.definitions.get(subscribe.messageName);
if (!definition) {
throw new Error(`AddLogged unknown message_name: ${subscribe.messageName}`);
}
this.#subscriptions.set(subscribe.msgId, { ...definition, multiId: subscribe.multiId });
}
}
async function isDataSectionStart(reader: ChunkedReader): Promise<boolean> {
const type = (await reader.peekUint8(2)) as MessageType;
switch (type) {
case MessageType.AddLogged:
case MessageType.RemoveLogged:
case MessageType.Data:
case MessageType.Log:
case MessageType.LogTagged:
case MessageType.Synchronization:
case MessageType.Dropout:
return true;
case MessageType.Unknown:
case MessageType.FlagBits:
case MessageType.Information:
case MessageType.InformationMulti:
case MessageType.FormatDefinition:
case MessageType.Parameter:
case MessageType.ParameterDefault:
return false;
}
}
function isValidInfoField(field: Field | undefined): field is Field {
return field?.isComplex === false;
}
function isValidParameter(field: Field | undefined): field is Field {
return Boolean(
field && (field.type === "int32_t" || field.type === "float") && field.arrayLength == undefined,
);
}
function sortTimeIndex(a: IndexEntry, b: IndexEntry) {
const timestampA = a[0];
const timestampB = b[0];
// If the timestamps are the same, sort by the offset within the file
if (timestampA === timestampB) {
const indexA = a[1];
const indexB = b[1];
return indexA - indexB;
}
return Number(timestampA - timestampB);
}
export function computeTimetampOffset(
definition: MessageDefinition,
definitions: Map<string, MessageDefinition>,
): number {
let curOffset = 0;
for (const field of definition.fields) {
if (field.name.startsWith("_")) {
continue;
}
if (field.name === "timestamp") {
if (field.type !== "uint64_t") {
throw new Error(
`Message "${definition.name}" has a timestamp field with a non-uint64_t type`,
);
}
return curOffset;
}
curOffset += fieldSize(field, definitions) * (field.arrayLength ?? 1);
}
throw new Error(`Message "${definition.name}" is missing a timestamp field`);
}