diff --git a/README.md b/README.md index 2fa7e6d..204b5a0 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,9 @@ import { MessageReader } from "@foxglove/rosmsg2-serialization"; // message definition comes from `parse()` in @foxglove/rosmsg const reader = new MessageReader(messageDefinition); +// specify a different `timeType` for time objects compatible with ROS 1 and @foxglove/rostime +const reader = new MessageReader(messageDefinition, { timeType: "sec,nsec" }); + // deserialize a buffer into an object const message = reader.readMessage([0x00, 0x01, ...]); diff --git a/eslint.config.cjs b/eslint.config.cjs index 8b0ccb3..74328dc 100644 --- a/eslint.config.cjs +++ b/eslint.config.cjs @@ -14,6 +14,9 @@ module.exports = tseslint.config( project: "./tsconfig.json", }, }, + rules: { + "@typescript-eslint/explicit-member-accessibility": "error", + }, }, ...foxglove.configs.base, ...foxglove.configs.jest, diff --git a/package.json b/package.json index 5687f4d..01d1510 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@foxglove/rosmsg2-serialization", - "version": "2.0.4", + "version": "3.0.0", "description": "ROS 2 (Robot Operating System) message serialization, for reading and writing bags and network messages", "license": "MIT", "keywords": [ @@ -39,11 +39,12 @@ "test": "jest" }, "engines": { - "node": ">= 14" + "node": ">= 20" }, "devDependencies": { "@foxglove/eslint-plugin": "2.0.0", - "@foxglove/rosmsg": "4.2.1", + "@foxglove/ros2idl-parser": "0.3.5", + "@foxglove/rosmsg": "5.0.4", "@sounisi5011/jest-binary-data-matchers": "1.2.1", "@types/jest": "^29.4.0", "@types/prettier": "^3.0.0", @@ -55,8 +56,8 @@ "typescript-eslint": "8.17.0" }, "dependencies": { - "@foxglove/cdr": "^3.0.0", - "@foxglove/message-definition": "^0.2.0", + "@foxglove/cdr": "^3.3.0", + "@foxglove/message-definition": "^0.4.0", "@foxglove/rostime": "^1.1.2" }, "packageManager": "yarn@4.5.3" diff --git a/src/MessageReader.test.ts b/src/MessageReader.test.ts index 34a46a3..dc6cf11 100644 --- a/src/MessageReader.test.ts +++ b/src/MessageReader.test.ts @@ -1,4 +1,5 @@ -import { parse as parseMessageDefinition, parseRos2idl } from "@foxglove/rosmsg"; +import { parseRos2idl } from "@foxglove/ros2idl-parser"; +import { parse as parseMessageDefinition } from "@foxglove/rosmsg"; import { MessageReader } from "./MessageReader"; @@ -51,26 +52,6 @@ describe("MessageReader", () => { // eslint-disable-next-line no-loss-of-precision { sample: 0.123456789121212121212 }, ], - [ - `time stamp`, - [0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00], - { - stamp: { - sec: 0, - nsec: 1, - }, - }, - ], - [ - `duration stamp`, - [0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00], - { - stamp: { - sec: 0, - nsec: 1, - }, - }, - ], [ `int32[] arr`, [ @@ -79,26 +60,6 @@ describe("MessageReader", () => { ], { arr: Int32Array.from([3, 7]) }, ], - [ - `time[1] arr`, - [0x01, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00], - { arr: [{ sec: 1, nsec: 2 }] }, - ], - [ - `duration[1] arr`, - [0x01, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00], - { arr: [{ sec: 1, nsec: 2 }] }, - ], - [ - `time[] arr`, - [0x01, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00], - { arr: [{ sec: 2, nsec: 3 }] }, - ], - [ - `duration[] arr`, - [0x01, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00], - { arr: [{ sec: 2, nsec: 3 }] }, - ], // unaligned access [ `uint8 blank\nint32[] arr`, @@ -110,15 +71,6 @@ describe("MessageReader", () => { ], { blank: 0, arr: Int32Array.from([3, 7]) }, ], - [ - `uint8 blank\ntime[] arr`, - [ - 0x00, - ...[0x00, 0x00, 0x00], // alignment - ...[0x01, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00], - ], - { blank: 0, arr: [{ sec: 2, nsec: 3 }] }, - ], [`float32[2] arr`, float32Buffer([5.5, 6.5]), { arr: Float32Array.from([5.5, 6.5]) }], [ `uint8 blank\nfloat32[2] arr`, @@ -275,6 +227,82 @@ describe("MessageReader", () => { }, ); + it.each([ + [ + `time stamp`, + [0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00], + { stamp: { sec: 0, nanosec: 1 } }, + { stamp: { sec: 0, nsec: 1 } }, + ], + [ + `duration stamp`, + [0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00], + { stamp: { sec: 0, nanosec: 1 } }, + { stamp: { sec: 0, nsec: 1 } }, + ], + [ + `time[1] arr`, + [0x01, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00], + { arr: [{ sec: 1, nanosec: 2 }] }, + { arr: [{ sec: 1, nsec: 2 }] }, + ], + [ + `duration[1] arr`, + [0x01, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00], + { arr: [{ sec: 1, nanosec: 2 }] }, + { arr: [{ sec: 1, nsec: 2 }] }, + ], + [ + `time[] arr`, + [0x01, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00], + { arr: [{ sec: 2, nanosec: 3 }] }, + { arr: [{ sec: 2, nsec: 3 }] }, + ], + [ + `duration[] arr`, + [0x01, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00], + { arr: [{ sec: 2, nanosec: 3 }] }, + { arr: [{ sec: 2, nsec: 3 }] }, + ], + // unaligned access + [ + `uint8 blank\ntime[] arr`, + [ + 0x00, + ...[0x00, 0x00, 0x00], // alignment + ...[0x01, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00], + ], + { blank: 0, arr: [{ sec: 2, nanosec: 3 }] }, + { blank: 0, arr: [{ sec: 2, nsec: 3 }] }, + ], + ])( + "should deserialize %s correctly based on specified time type", + ( + msgDef: string, + arr: Iterable, + expected: Record, + ros1Expected: Record, + ) => { + const buffer = Uint8Array.from([0, 1, 0, 0, ...arr]); + + expect( + new MessageReader(parseMessageDefinition(msgDef, { ros2: true })).readMessage(buffer), + ).toEqual(expected); + + expect( + new MessageReader(parseMessageDefinition(msgDef, { ros2: true }), { + timeType: "sec,nanosec", + }).readMessage(buffer), + ).toEqual(expected); + + expect( + new MessageReader(parseMessageDefinition(msgDef, { ros2: true }), { + timeType: "sec,nsec", + }).readMessage(buffer), + ).toEqual(ros1Expected); + }, + ); + it("should deserialize a ROS 2 log message", () => { const buffer = Uint8Array.from( Buffer.from( @@ -303,7 +331,7 @@ describe("MessageReader", () => { const read = reader.readMessage(buffer); expect(read).toEqual({ - stamp: { sec: 1585866235, nsec: 112130688 }, + stamp: { sec: 1585866235, nanosec: 112130688 }, level: 20, name: "minimal_publisher", msg: "Publishing: 'Hello, world! 0'", @@ -321,27 +349,27 @@ describe("MessageReader", () => { ), ); const msgDef = ` - geometry_msgs/msg/TransformStamped[] transforms + geometry_msgs/TransformStamped[] transforms ================================================================================ - MSG: geometry_msgs/msg/TransformStamped + MSG: geometry_msgs/TransformStamped Header header string child_frame_id # the frame id of the child frame Transform transform ================================================================================ - MSG: std_msgs/msg/Header + MSG: std_msgs/Header time stamp string frame_id ================================================================================ - MSG: geometry_msgs/msg/Transform + MSG: geometry_msgs/Transform Vector3 translation Quaternion rotation ================================================================================ - MSG: geometry_msgs/msg/Vector3 + MSG: geometry_msgs/Vector3 float64 x float64 y float64 z ================================================================================ - MSG: geometry_msgs/msg/Quaternion + MSG: geometry_msgs/Quaternion float64 x float64 y float64 z @@ -354,7 +382,7 @@ describe("MessageReader", () => { transforms: [ { header: { - stamp: { sec: 1638821672, nsec: 836230505 }, + stamp: { sec: 1638821672, nanosec: 836230505 }, frame_id: "turtle1", }, child_frame_id: "turtle1_ahead", @@ -454,7 +482,7 @@ IDL: builtin_interfaces/Time module builtin_interfaces { struct Time { int32 sec; - uint32 nanosec; + uint32 nsec; }; }; `; @@ -465,7 +493,7 @@ module builtin_interfaces { transforms: [ { header: { - stamp: { sec: 1638821672, nanosec: 836230505 }, + stamp: { sec: 1638821672, nsec: 836230505 }, frame_id: "turtle1", }, child_frame_id: "turtle1_ahead", diff --git a/src/MessageReader.ts b/src/MessageReader.ts index f435222..341e310 100644 --- a/src/MessageReader.ts +++ b/src/MessageReader.ts @@ -1,9 +1,14 @@ import { CdrReader } from "@foxglove/cdr"; import { MessageDefinition, MessageDefinitionField } from "@foxglove/message-definition"; -import { Time } from "@foxglove/rostime"; +import { Time as Ros1Time } from "@foxglove/rostime"; -export type Deserializer = (reader: CdrReader) => boolean | number | bigint | string | Time; -export type ArrayDeserializer = ( +type Ros2Time = { + sec: number; + nanosec: number; +}; + +type Deserializer = (reader: CdrReader) => boolean | number | bigint | string | Ros1Time | Ros2Time; +type ArrayDeserializer = ( reader: CdrReader, count: number, ) => @@ -19,35 +24,50 @@ export type ArrayDeserializer = ( | Float32Array | Float64Array | string[] - | Time[]; + | Ros1Time[] + | Ros2Time[]; + +export type MessageReaderOptions = { + /** + * Select the type for deserialized `time` and `duration` values. "sec" and "nanosec" are used by + * default in ROS 2, whereas "sec" and "nsec" originates from ROS 1 and matches + * `@foxglove/rostime`. + * + * @default "sec,nanosec" + */ + timeType?: "sec,nanosec" | "sec,nsec"; +}; export class MessageReader { - rootDefinition: MessageDefinitionField[]; - definitions: Map; + #rootDefinition: MessageDefinitionField[]; + #definitions: Map; + #useRos1Time: boolean; + + public constructor(definitions: MessageDefinition[], options: MessageReaderOptions = {}) { + const { timeType = "sec,nanosec" } = options; - constructor(definitions: MessageDefinition[]) { // ros2idl modules could have constant modules before the root struct used to decode message const rootDefinition = definitions.find((def) => !isConstantModule(def)); if (rootDefinition == undefined) { throw new Error("MessageReader initialized with no root MessageDefinition"); } - this.rootDefinition = rootDefinition.definitions; - this.definitions = new Map( + this.#rootDefinition = rootDefinition.definitions; + this.#definitions = new Map( definitions.map((def) => [def.name ?? "", def.definitions]), ); + this.#useRos1Time = timeType === "sec,nsec"; } // We template on R here for call site type information if the class type information T is not // known or available // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters - readMessage(buffer: ArrayBufferView): R { + public readMessage(buffer: ArrayBufferView): R { const reader = new CdrReader(buffer); - return this.readComplexType(this.rootDefinition, reader) as R; + return this.#readComplexType(this.#rootDefinition, reader) as R; } - // eslint-disable-next-line @foxglove/prefer-hash-private - private readComplexType( + #readComplexType( definition: MessageDefinitionField[], reader: CdrReader, ): Record { @@ -69,7 +89,7 @@ export class MessageReader { if (field.isComplex === true) { // Complex type - const nestedDefinition = this.definitions.get(field.type); + const nestedDefinition = this.#definitions.get(field.type); if (nestedDefinition == undefined) { throw new Error(`Unrecognized complex type ${field.type}`); } @@ -79,16 +99,18 @@ export class MessageReader { const arrayLength = field.arrayLength ?? reader.sequenceLength(); const array = []; for (let i = 0; i < arrayLength; i++) { - array.push(this.readComplexType(nestedDefinition, reader)); + array.push(this.#readComplexType(nestedDefinition, reader)); } msg[field.name] = array; } else { - msg[field.name] = this.readComplexType(nestedDefinition, reader); + msg[field.name] = this.#readComplexType(nestedDefinition, reader); } } else { // Primitive type if (field.isArray === true) { - const deser = typedArrayDeserializers.get(field.type); + const deser = ( + this.#useRos1Time ? ros1TypedArrayDeserializers : typedArrayDeserializers + ).get(field.type); if (deser == undefined) { throw new Error(`Unrecognized primitive array type ${field.type}[]`); } @@ -96,7 +118,7 @@ export class MessageReader { const arrayLength = field.arrayLength ?? reader.sequenceLength(); msg[field.name] = deser(reader, arrayLength); } else { - const deser = deserializers.get(field.type); + const deser = (this.#useRos1Time ? ros1TimeDeserializers : deserializers).get(field.type); if (deser == undefined) { throw new Error(`Unrecognized primitive type ${field.type}`); } @@ -125,9 +147,14 @@ const deserializers = new Map([ ["float32", (reader) => reader.float32()], ["float64", (reader) => reader.float64()], ["string", (reader) => reader.string()], + ["wstring", throwOnWstring], + ["time", (reader) => ({ sec: reader.int32(), nanosec: reader.uint32() })], + ["duration", (reader) => ({ sec: reader.int32(), nanosec: reader.uint32() })], +]); +const ros1TimeDeserializers = new Map([ + ...deserializers, ["time", (reader) => ({ sec: reader.int32(), nsec: reader.uint32() })], ["duration", (reader) => ({ sec: reader.int32(), nsec: reader.uint32() })], - ["wstring", throwOnWstring], ]); const typedArrayDeserializers = new Map([ @@ -143,9 +170,14 @@ const typedArrayDeserializers = new Map([ ["float32", (reader, count) => reader.float32Array(count)], ["float64", (reader, count) => reader.float64Array(count)], ["string", readStringArray], + ["wstring", throwOnWstring], ["time", readTimeArray], ["duration", readTimeArray], - ["wstring", throwOnWstring], +]); +const ros1TypedArrayDeserializers = new Map([ + ...typedArrayDeserializers, + ["time", readRos1TimeArray], + ["duration", readRos1TimeArray], ]); function readBoolArray(reader: CdrReader, count: number): boolean[] { @@ -164,8 +196,8 @@ function readStringArray(reader: CdrReader, count: number): string[] { return array; } -function readTimeArray(reader: CdrReader, count: number): Time[] { - const array = new Array