diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae053f2..8574c97 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [22, 24] + node-version: [18, 20, 22, 24] steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 04b9ee0..361c86b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,7 @@ Keep changes small, protocol-focused and backward-compatible whenever possible. ## Development ```bash -npm install +npm ci npm run check ``` diff --git a/README.md b/README.md index 3dd5574..27f67c8 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Supported: - Augmenta WebSocket protocol V2 binary data; - current Pleiades V3 bundle/object/scene extensions, including timestamps and UUID-based object packets; +- explicit protocol-version guard: only verified V2/V3 layouts are accepted; - clusters, point clouds and scene information; - zone enter/leave/presence/density events; - zone slider, XY pad and optional zone point-cloud properties; @@ -33,8 +34,7 @@ Legacy binary protocol V1 is intentionally not implemented in this first version ## TODO before 1.0 -- Validate V2/V3 end-to-end against live Pleiades streams and keep captured binary fixtures. -- Expand regression coverage for standalone point clouds, cluster + point-cloud packets, zone properties, and malformed/truncated packets. +- Validate the committed V2/V3 protocol-writer fixtures against live Pleiades captures. - Decide whether the WebSocket convenience client should handle protocol negotiation/fallback automatically. - Exercise the SDK in browser, Node.js, and Max/MSP / Max for Live integrations. - Finalize npm publishing, release notes/changelog, and stable 1.0 documentation. @@ -50,7 +50,7 @@ npm install augmenta-client-sdk From this repository during development: ```bash -npm install +npm ci npm run build npm test ``` @@ -189,8 +189,8 @@ const options = new ProtocolOptions({ 2. **Same concepts across SDKs** — `Client`, `ProtocolOptions`, `DataBlob` and `ControlMessage` stay recognizable across C++, C# and JavaScript. 3. **Transport independent** — parsing is usable from a browser, Node.js, Max/MSP, tests or another transport. 4. **Small and predictable** — no framework and no runtime dependency. -5. **Forward-compatible parsing** — packet/property sizes are respected so unknown future properties can be skipped safely. -6. **Web-friendly data** — arrays and typed arrays are exposed directly and can be fed efficiently into rendering/application code. +5. **Forward-compatible parsing** — packet/property sizes are respected so unknown future properties/packet families can be skipped safely within their declared boundaries. +6. **Web-friendly data** — arrays and typed arrays are exposed directly and the point parser uses bulk typed-array paths for large clouds instead of one JavaScript read per coordinate. ## License diff --git a/docs/API.md b/docs/API.md index 5c12cad..70e150f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -6,7 +6,7 @@ Negotiates what an Augmenta WebSocket Output sends to the client. Important options include: -- `version` — protocol version, V2 by default; +- `version` — protocol version, V2 by default; this beta accepts V2 and V3; - `tags` — optional server-side Augmenta tags; - `downSample` — point-cloud downsampling factor; - `streamClouds` — raw scene point clouds; @@ -16,7 +16,8 @@ Important options include: - `boxRotationMode` — radians, degrees or quaternions; - `axisTransform` — coordinate system transformation requested from the server; - `useCompression` — request Zstd-compressed binary frames; -- `usePolling` — request data only when `poll` is sent. +- `usePolling` — request data only when `poll` is sent; +- `displayPointIntensity` — tells the parser to consume point-intensity values when the server stream contains them. The core `ProtocolOptions` defaults follow the C++ SDK. The `AugmentaWebSocketClient` convenience transport defaults to uncompressed frames unless explicit `ProtocolOptions` are supplied, so a zero-configuration browser client does not require Zstd. @@ -64,7 +65,7 @@ An object can contain: - point-cloud data; - both. -Cluster data includes state, centroid, velocity, bounding-box center/size/rotation, weight and look-at vector. +Cluster data includes state, centroid, velocity, bounding-box center/size/rotation, weight and look-at vector. `getBoundingBoxRotationEuler()` is only valid for degree/radian rotation modes; `getBoundingBoxRotationQuaternions()` is only valid for quaternion mode. Point clouds expose packed XYZ coordinates through `Float32Array`. V3 object packets also expose the UUID sent by Pleiades and, for clusters, the readable ID carried in the cluster property. @@ -93,4 +94,4 @@ Events: - `update` - `data` -The class accepts a custom `webSocketFactory`, which is useful for Node runtimes or Max/MSP environments that provide their own WebSocket package. +The class accepts a custom `webSocketFactory`, which is useful for Node runtimes or Max/MSP environments that provide their own WebSocket package. Delayed events from an older socket are ignored after reconnect, and `poll()` requires the socket to be open. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 9fafee3..616d5b8 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -28,13 +28,13 @@ Supported zone properties: Pleiades currently extends V3 with UUID-based object packets, a readable cluster ID inside the cluster property, a server millisecond timestamp in the bundle header, and a scene timestamp. The JavaScript SDK parses those fields directly: `DataBlob.timestamp`, `SceneInfoPacket.timestamp`, `ObjectPacket.uuid` and the readable `ObjectPacket.id` when a cluster provides one. -## V1 +## Supported version range -Legacy binary protocol V1 uses a different framing scheme and is not parsed by this first JavaScript SDK version. The client fails explicitly rather than silently interpreting V1 data with the V2 layout. +This beta accepts protocol V2 and V3. Legacy V1 uses a different framing scheme and is rejected explicitly. Versions newer than V3 are also rejected until their wire compatibility has been verified. ## Forward compatibility -The parser treats packet and property sizes emitted by Pleiades as authoritative. Known fields are parsed and unknown object/zone properties are skipped to their declared boundary. This avoids desynchronizing the rest of a bundle when a newer server adds data the current SDK does not yet understand. +The parser treats packet and property sizes emitted by Pleiades as authoritative. Known fields are parsed and unknown object/zone properties and unknown packet families are skipped to their declared boundary. Nested packets are constrained to their enclosing bundle/property boundary so malformed sizes cannot consume bytes outside their parent packet. ## Compression @@ -43,3 +43,8 @@ The wire protocol can use Zstd compression. Compression is deliberately separate ## Cross-SDK parity The public concepts intentionally remain close to the C++ and C# SDKs. When the wire protocol changes, protocol fixtures should be used to verify equivalent results across SDK implementations. + + +## Regression fixtures + +`tests/fixtures` contains deterministic V2/V3 wire fixtures mirroring the current Pleiades `develop` writer layout. They cover standalone and tracked point clouds, intensity arrays, slider/XY/zone point-cloud properties, and V3 timestamps/UUIDs/readable IDs. Large-cloud and malformed-boundary cases are also exercised separately in the automated tests. diff --git a/src/binary.ts b/src/binary.ts index 447d684..c2e7f10 100644 --- a/src/binary.ts +++ b/src/binary.ts @@ -16,6 +16,7 @@ export type BinaryData = ArrayBuffer | ArrayBufferView; export type Decompressor = (data: Uint8Array) => Uint8Array; const textDecoder = new TextDecoder(); +const littleEndian = new Uint8Array(new Uint16Array([1]).buffer)[0] === 1; function asUint8Array(data: BinaryData): Uint8Array { if (data instanceof ArrayBuffer) return new Uint8Array(data); @@ -66,8 +67,33 @@ class Reader { if (!Number.isInteger(count) || count < 0) { throw new RangeError(`Invalid float count while reading ${context}.`); } - const output = new Float32Array(count); - for (let i = 0; i < count; i++) output[i] = this.f32(context); + + const byteLength = count * Float32Array.BYTES_PER_ELEMENT; + if (!Number.isSafeInteger(byteLength)) { + throw new RangeError(`Invalid float byte length while reading ${context}.`); + } + this.ensure(byteLength, context); + + const absoluteOffset = this.bytes.byteOffset + this.offset; + let output: Float32Array; + + if (littleEndian && absoluteOffset % Float32Array.BYTES_PER_ELEMENT === 0) { + output = new Float32Array(this.bytes.buffer, absoluteOffset, count); + } else if (littleEndian) { + // Pleiades' compact packet headers often leave float payloads unaligned. + // Copy the byte range natively instead of issuing one DataView read per + // coordinate; this is substantially cheaper for large point clouds. + output = new Float32Array( + this.bytes.buffer.slice(absoluteOffset, absoluteOffset + byteLength) + ); + } else { + output = new Float32Array(count); + for (let i = 0; i < count; i++) { + output[i] = this.view.getFloat32(this.offset + i * 4, true); + } + } + + this.offset += byteLength; return output; } @@ -105,11 +131,26 @@ interface ParsedBlobState { timestamp?: number; } +function ensureWithin(reader: Reader, end: number, size: number, context: string): void { + if ( + !Number.isSafeInteger(size) + || size < 0 + || reader.offset + size > end + || end > reader.length + ) { + throw new RangeError(`Malformed Augmenta packet while reading ${context}.`); + } +} + function parsePointCloud(reader: Reader, options: ProtocolOptions, end: number): PointCloudProperty { + ensureWithin(reader, end, 4, 'point count'); const pointCount = reader.i32('point count'); if (pointCount < 0) throw new RangeError('Malformed Augmenta point count.'); - const points = reader.floats(pointCount * 3, 'point cloud coordinates'); + const coordinateCount = pointCount * 3; + const coordinateBytes = coordinateCount * Float32Array.BYTES_PER_ELEMENT; + ensureWithin(reader, end, coordinateBytes, 'point cloud coordinates'); + const points = reader.floats(coordinateCount, 'point cloud coordinates'); let intensity: Float32Array | undefined; if (options.displayPointIntensity && reader.offset + pointCount * 4 <= end) { intensity = reader.floats(pointCount, 'point cloud intensity'); @@ -120,14 +161,28 @@ function parsePointCloud(reader: Reader, options: ProtocolOptions, end: number): : new PointCloudProperty(points, intensity); } -function parseCluster(reader: Reader, options: ProtocolOptions): { cluster: ClusterProperty; readableID?: number } { +function parseCluster( + reader: Reader, + options: ProtocolOptions, + end: number +): { cluster: ClusterProperty; readableID?: number } { + const rotationCount = options.boxRotationMode === RotationMode.Quaternions ? 4 : 3; + const requiredBytes = ( + 4 + + 4 * 3 * 4 + + 4 + + rotationCount * 4 + + 3 * 4 + + (options.version >= 3 ? 4 : 0) + ); + ensureWithin(reader, end, requiredBytes, 'cluster property'); + const state = reader.i32('cluster state') as ClusterState; const centroid = reader.vec3('cluster centroid'); const velocity = reader.vec3('cluster velocity'); const boundingBoxCenter = reader.vec3('bounding box center'); const boundingBoxSize = reader.vec3('bounding box size'); const weight = reader.f32('cluster weight'); - const rotationCount = options.boxRotationMode === RotationMode.Quaternions ? 4 : 3; const rotation = Array.from(reader.floats(rotationCount, 'bounding box rotation')); const lookAt = reader.vec3('cluster look-at'); @@ -153,6 +208,8 @@ function formatUUID(bytes: Uint8Array): string { } function parseObject(reader: Reader, options: ProtocolOptions, packetEnd: number): ObjectPacket { + ensureWithin(reader, packetEnd, options.version >= 3 ? 20 : 8, 'object header'); + let id: number | undefined; let uuid: string | undefined; if (options.version >= 3) { @@ -169,6 +226,7 @@ function parseObject(reader: Reader, options: ProtocolOptions, packetEnd: number let pointCloud: PointCloudProperty | undefined; for (let i = 0; i < propertiesCount; i++) { + ensureWithin(reader, packetEnd, 8, 'object property header'); const propertyStart = reader.offset; const propertySize = reader.i32('object property size'); const propertyType = reader.i32('object property type') as ObjectPropertyType; @@ -180,7 +238,7 @@ function parseObject(reader: Reader, options: ProtocolOptions, packetEnd: number if (propertyType === ObjectPropertyType.Points) { pointCloud = parsePointCloud(reader, options, propertyEnd); } else if (propertyType === ObjectPropertyType.Cluster) { - const parsedCluster = parseCluster(reader, options); + const parsedCluster = parseCluster(reader, options, propertyEnd); cluster = parsedCluster.cluster; if (parsedCluster.readableID !== undefined) id = parsedCluster.readableID; } @@ -195,8 +253,10 @@ function parseObject(reader: Reader, options: ProtocolOptions, packetEnd: number } function parseZoneEvent(reader: Reader, options: ProtocolOptions, packetEnd: number): ZoneEventPacket { + ensureWithin(reader, packetEnd, 4, 'zone address size'); const addressSize = reader.i32('zone address size'); if (addressSize < 0) throw new RangeError('Malformed Augmenta zone address size.'); + ensureWithin(reader, packetEnd, addressSize + 14, 'zone header'); const address = reader.string(addressSize, 'zone address'); const enters = reader.u8('zone enters'); const leaves = reader.u8('zone leaves'); @@ -207,6 +267,7 @@ function parseZoneEvent(reader: Reader, options: ProtocolOptions, packetEnd: num const properties: ZoneEventProperty[] = []; for (let i = 0; i < propertiesCount; i++) { + ensureWithin(reader, packetEnd, 5, 'zone property header'); const propertyStart = reader.offset; const propertySize = reader.i32('zone property size'); const propertyType = reader.u8('zone property type') as ZonePropertyType; @@ -216,8 +277,10 @@ function parseZoneEvent(reader: Reader, options: ProtocolOptions, packetEnd: num } if (propertyType === ZonePropertyType.Slider) { + ensureWithin(reader, propertyEnd, 4, 'zone slider'); properties.push(new ZoneEventProperty(propertyType, { value: reader.f32('zone slider') })); } else if (propertyType === ZonePropertyType.XYPad) { + ensureWithin(reader, propertyEnd, 8, 'zone XY pad'); properties.push(new ZoneEventProperty(propertyType, { x: reader.f32('zone XY pad x'), y: reader.f32('zone XY pad y') @@ -232,40 +295,60 @@ function parseZoneEvent(reader: Reader, options: ProtocolOptions, packetEnd: num return new ZoneEventPacket(address, enters, leaves, presence, density, properties); } -function parseScene(reader: Reader, options: ProtocolOptions): SceneInfoPacket { +function parseScene(reader: Reader, options: ProtocolOptions, packetEnd: number): SceneInfoPacket { + ensureWithin(reader, packetEnd, 4, 'scene address size'); const addressSize = reader.i32('scene address size'); if (addressSize < 0) throw new RangeError('Malformed Augmenta scene address size.'); + ensureWithin( + reader, + packetEnd, + addressSize + (options.version >= 3 ? 4 : 0), + 'scene packet' + ); const address = reader.string(addressSize, 'scene address'); if (options.version >= 3) return new SceneInfoPacket(address, reader.i32('scene timestamp')); return new SceneInfoPacket(address); } -function parsePacket(reader: Reader, state: ParsedBlobState, options: ProtocolOptions): void { +function parsePacket( + reader: Reader, + state: ParsedBlobState, + options: ProtocolOptions, + parentEnd = reader.length +): void { const packetStart = reader.offset; + ensureWithin(reader, parentEnd, 5, 'packet header'); const packetSize = reader.i32('packet size'); const type = reader.u8('packet type') as PacketType; const packetEnd = packetStart + packetSize; - if (packetSize < 5 || packetEnd > reader.length) { + if (packetSize < 5 || packetEnd > parentEnd || packetEnd > reader.length) { throw new RangeError('Malformed Augmenta packet size.'); } if (type === PacketType.Bundle) { + ensureWithin( + reader, + packetEnd, + (options.version >= 3 ? 4 : 0) + 4, + 'bundle header' + ); if (options.version >= 3) state.timestamp = reader.i32('bundle timestamp'); const packetCount = reader.i32('bundle packet count'); if (packetCount < 0) throw new RangeError('Malformed Augmenta bundle packet count.'); - for (let i = 0; i < packetCount; i++) parsePacket(reader, state, options); + for (let i = 0; i < packetCount; i++) { + parsePacket(reader, state, options, packetEnd); + } } else if (type === PacketType.Object) { state.objects.push(parseObject(reader, options, packetEnd)); } else if (type === PacketType.ZoneEvent) { state.zoneEvents.push(parseZoneEvent(reader, options, packetEnd)); } else if (type === PacketType.Scene) { - state.sceneInfo = parseScene(reader, options); - } else { - throw new Error(`Unknown Augmenta packet type ${type}.`); + state.sceneInfo = parseScene(reader, options, packetEnd); } + // Unknown packet families are skipped to their declared boundary. Packet size + // is authoritative, matching the forward-compatible property behavior. - // Packet size comes from Pleiades and is authoritative. It lets clients ignore future fields safely. reader.seek(packetEnd, 'packet'); } diff --git a/src/data.ts b/src/data.ts index ca9699f..3dd8160 100644 --- a/src/data.ts +++ b/src/data.ts @@ -38,7 +38,9 @@ export class ClusterProperty { getBoundingBoxSize(): Vector3 { return this.boundingBoxSize; } getWeight(): number { return this.weight; } getBoundingBoxRotationEuler(): Vector3 { - if (this.boundingBoxRotation.length < 3) throw new Error('Rotation data is unavailable.'); + if (this.boundingBoxRotation.length !== 3) { + throw new Error('Rotation mode is not Euler.'); + } return [this.boundingBoxRotation[0]!, this.boundingBoxRotation[1]!, this.boundingBoxRotation[2]!]; } getBoundingBoxRotationQuaternions(): Vector4 { diff --git a/src/options.ts b/src/options.ts index c42f9a9..a54f114 100644 --- a/src/options.ts +++ b/src/options.ts @@ -1,3 +1,6 @@ +export const MIN_SUPPORTED_PROTOCOL_VERSION = 2; +export const MAX_SUPPORTED_PROTOCOL_VERSION = 3; + export enum RotationMode { Radians = 'radians', Degrees = 'degrees', @@ -91,8 +94,14 @@ export class ProtocolOptions { if (axisTransform) this.axisTransform = new AxisTransform(axisTransform); if (tags) this.tags = [...tags]; - if (!Number.isInteger(this.version) || this.version < 1) { - throw new RangeError('Protocol version must be a positive integer.'); + if ( + !Number.isInteger(this.version) + || this.version < MIN_SUPPORTED_PROTOCOL_VERSION + || this.version > MAX_SUPPORTED_PROTOCOL_VERSION + ) { + throw new RangeError( + `Protocol version must be an integer between ${MIN_SUPPORTED_PROTOCOL_VERSION} and ${MAX_SUPPORTED_PROTOCOL_VERSION}.` + ); } if (!Number.isInteger(this.downSample) || this.downSample < 1) { throw new RangeError('downSample must be an integer greater than or equal to 1.'); diff --git a/src/websocket.ts b/src/websocket.ts index af4902e..ee51c60 100644 --- a/src/websocket.ts +++ b/src/websocket.ts @@ -93,20 +93,32 @@ export class AugmentaWebSocketClient { if ('binaryType' in socket) socket.binaryType = 'arraybuffer'; socket.addEventListener('open', (event) => { + if (this.socket !== socket) return; socket.send(this.client.getRegisterMessage()); this.emit('open', event); }); socket.addEventListener('message', (event) => { - void this.handleMessage(event.data).catch((error: unknown) => this.emit('error', error)); + if (this.socket !== socket) return; + void this.handleMessage(event.data, socket).catch((error: unknown) => { + if (this.socket === socket) this.emit('error', error); + }); }); socket.addEventListener('close', (event) => { - this.socket = undefined; - this.emit('close', event); + if (this.socket === socket) { + this.socket = undefined; + this.emit('close', event); + } else if (this.socket === undefined) { + // Preserve the close event for an explicit disconnect, but never let a + // delayed close from an old socket disturb a newer connection. + this.emit('close', event); + } }); - socket.addEventListener('error', (event) => this.emit('error', event)); + socket.addEventListener('error', (event) => { + if (this.socket === socket) this.emit('error', event); + }); } disconnect(code?: number, reason?: string): void { @@ -116,13 +128,17 @@ export class AugmentaWebSocketClient { } poll(): void { - if (!this.socket) throw new Error('WebSocket client is not connected.'); + if (!this.socket || this.socket.readyState !== 1) { + throw new Error('WebSocket client is not open.'); + } this.socket.send(this.client.getPollMessage()); } getSocket(): WebSocketLike | undefined { return this.socket; } - private async handleMessage(data: unknown): Promise { + private async handleMessage(data: unknown, sourceSocket: WebSocketLike): Promise { + if (this.socket !== sourceSocket) return; + if (typeof data === 'string') { const message = this.client.parseControlMessage(data); this.emit('controlMessage', message); @@ -137,6 +153,7 @@ export class AugmentaWebSocketClient { else if (typeof Blob !== 'undefined' && data instanceof Blob) binary = await data.arrayBuffer(); else throw new TypeError('Unsupported WebSocket message type.'); + if (this.socket !== sourceSocket) return; this.emit('data', this.client.parseDataBlob(binary)); } diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 0000000..9596e72 --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,13 @@ +# Pleiades wire fixtures + +These deterministic binary fixtures mirror the byte layout emitted by the +current Pleiades `ProtocolDataBundleBuilder` / object / zone writers on +`develop` commit `aac5de74c35560bf48c421aa8895c81a79e6479a`. + +They intentionally cover data that is easy to regress in a JavaScript parser: +standalone point clouds, cluster + point-cloud packets, intensity arrays, +slider/XY values, zone point clouds, V3 UUIDs/readable IDs and timestamps. + +The files are stored as hexadecimal text so they remain reviewable in Git. +Tests decode them to bytes before parsing. They are protocol-writer fixtures, +not a hardware/live-session capture. diff --git a/tests/fixtures/pleiades-v2.hex b/tests/fixtures/pleiades-v2.hex new file mode 100644 index 0000000..6683440 --- /dev/null +++ b/tests/fixtures/pleiades-v2.hex @@ -0,0 +1,8 @@ +6f010000ff0400000017000000020e0000002f666978747572652f7363656e65390000000000000000010000002c0000 +0000000000020000000000803f00000040000040400000c0bf0000803e00009040cdcccc3d6666663f95000000002a00 +0000020000005c00000001000000010000000000803f0000004000004040cdcccc3dcdcc4c3e9a99993e0000803f0000 +803f0000803f0000003f6666e63f9a99193f0000403f0000000000000000000000000000803f00000000000000000000 +803f2c0000000000000002000000cdcc8c3fcdcc0c40333353409a99993f333313409a995940cdcccc3e0000003f8100 +000001130000002f666978747572652f7363656e652f7a6f6e65010002000000c3f5a83e030000001100000000000080 +3ecdcc4c3fcdcccc3d1d00000001cdcc4c3e3333333f6666663fcdcc4c3fcdcccc3dcdcc4c3e29000000020200000000 +00004000000000000040400000204000000000000060409a99193f3333333f diff --git a/tests/fixtures/pleiades-v3.hex b/tests/fixtures/pleiades-v3.hex new file mode 100644 index 0000000..3227fe0 --- /dev/null +++ b/tests/fixtures/pleiades-v3.hex @@ -0,0 +1,6 @@ +03010000ffb1cb7400030000001600000002090000002f76332f7363656e65d2040000a5000000000011223344556677 +8899aabbccddeeff020000006000000001000000000000000000a0400000803f0000c040cdcccc3e000000bf9a99193f +0000a0406666663f0000c0409a99193f9a99d93f3333333fae47613f00000000cdcc4c3e00000000e7d37a3f0000803f +00000000000000004d0000002c00000000000000020000000000a040000000000000c0406666a6400000803f3333c340 +cdcc4c3ecdcc4c3f3b000000010e0000002f76332f7363656e652f7a6f6e650001000000000000000002000000090000 +00000000003f0d00000001cdcccc3e9a99193f diff --git a/tests/sdk.test.mjs b/tests/sdk.test.mjs index f40b210..5c3c5b9 100644 --- a/tests/sdk.test.mjs +++ b/tests/sdk.test.mjs @@ -1,10 +1,12 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createRequire } from 'node:module'; +import { readFileSync } from 'node:fs'; import { AugmentaWebSocketClient, AxisMode, Client, + ClusterProperty, ClusterState, ContainerType, CoordinateSpace, @@ -29,6 +31,30 @@ const u8 = (value) => Uint8Array.of(value); const str = (value) => encoder.encode(value); const packet = (type, payload) => concat(i32(5 + payload.length), u8(type), payload); +function fixtureBytes(name) { + const hex = readFileSync(new URL(`./fixtures/${name}.hex`, import.meta.url), 'utf8') + .replace(/\s+/g, ''); + return Uint8Array.from(hex.match(/.{2}/g) ?? [], (byte) => Number.parseInt(byte, 16)); +} + +function pointCloudObjectPacket(pointCount) { + const coordinates = new Uint8Array(pointCount * 3 * 4); + const view = new DataView(coordinates.buffer); + if (pointCount > 0) { + view.setFloat32(0, 1, true); + view.setFloat32(4, 2, true); + view.setFloat32(8, 3, true); + const last = (pointCount - 1) * 12; + view.setFloat32(last, -1, true); + view.setFloat32(last + 4, -2, true); + view.setFloat32(last + 8, -3, true); + } + + const propertyPayload = concat(i32(pointCount), coordinates); + const property = concat(i32(8 + propertyPayload.length), i32(0), propertyPayload); + return packet(0, concat(i32(7), i32(1), property)); +} + function scenePacket(address = '/world/scene', timestamp) { const addressBytes = str(address); const payload = timestamp === undefined @@ -322,3 +348,254 @@ test('WebSocket convenience defaults to uncompressed frames for zero-config web socket.emit('open', {}); assert.equal(JSON.parse(socket.sent[0]).register.options.useCompression, false); }); + + +test('ProtocolOptions rejects unsupported protocol versions', () => { + assert.throws(() => new ProtocolOptions({ version: 1 }), RangeError); + assert.doesNotThrow(() => new ProtocolOptions({ version: 2 })); + assert.doesNotThrow(() => new ProtocolOptions({ version: 3 })); + assert.throws(() => new ProtocolOptions({ version: 4 }), RangeError); +}); + +test('Rotation getters reject the wrong wire representation', () => { + const quaternion = new ClusterProperty( + ClusterState.Updated, + [0, 0, 0], + [0, 0, 0], + [0, 0, 0], + [1, 1, 1], + 1, + [0, 0, 0, 1], + [0, 0, 1] + ); + assert.throws(() => quaternion.getBoundingBoxRotationEuler(), /not Euler/); + assert.deepEqual(quaternion.getBoundingBoxRotationQuaternions(), [0, 0, 0, 1]); + + const euler = new ClusterProperty( + ClusterState.Updated, + [0, 0, 0], + [0, 0, 0], + [0, 0, 0], + [1, 1, 1], + 1, + [10, 20, 30], + [0, 0, 1] + ); + assert.deepEqual(euler.getBoundingBoxRotationEuler(), [10, 20, 30]); + assert.throws(() => euler.getBoundingBoxRotationQuaternions(), /not quaternion/); +}); + +test('Pleiades V2 wire fixture parses standalone/cluster/zone point clouds and properties', () => { + const client = new Client(); + client.initialize('fixture-v2', { + version: 2, + useCompression: false, + displayPointIntensity: true + }); + + const data = client.parseDataBlob(fixtureBytes('pleiades-v2')); + assert.equal(data.getSceneInfo().getAddress(), '/fixture/scene'); + assert.equal(data.getObjectCount(), 2); + + const standalone = data.getObjects()[0]; + assert.equal(standalone.getID(), 0); + assert.equal(standalone.hasCluster(), false); + assert.equal(standalone.getPointCloud().getPointCount(), 2); + assert.deepEqual(Array.from(standalone.getPointCloud().getPointsData()), [ + 1, 2, 3, -1.5, 0.25, 4.5 + ]); + assert.deepEqual( + Array.from(standalone.getPointCloud().getIntensityData(), (value) => Number(value.toFixed(3))), + [0.1, 0.9] + ); + + const tracked = data.getObjects()[1]; + assert.equal(tracked.getID(), 42); + assert.equal(tracked.hasCluster(), true); + assert.equal(tracked.hasPointCloud(), true); + assert.deepEqual( + tracked.getCluster().getVelocity().map((value) => Number(value.toFixed(3))), + [0.1, 0.2, 0.3] + ); + + const zone = data.getZoneEvents()[0]; + assert.equal(zone.getEmitterZoneAddress(), '/fixture/scene/zone'); + assert.equal(zone.getPresence(), 2); + assert.equal(zone.getProperties().length, 3); + assert.equal( + Number(zone.getProperties()[0].getSliderParameters().value.toFixed(3)), + 0.25 + ); + assert.deepEqual( + [ + zone.getProperties()[1].getXYPadParameters().x, + zone.getProperties()[1].getXYPadParameters().y + ].map((value) => Number(value.toFixed(3))), + [0.2, 0.7] + ); + assert.equal(zone.getProperties()[2].getPointCloudParameters().getPointCount(), 2); +}); + +test('Pleiades V3 wire fixture parses UUIDs, readable IDs and timestamps', () => { + const client = new Client(); + client.initialize('fixture-v3', { + version: 3, + useCompression: false, + displayPointIntensity: true + }); + + const data = client.parseDataBlob(fixtureBytes('pleiades-v3')); + assert.equal(data.timestamp, 7654321); + assert.equal(data.getSceneInfo().getAddress(), '/v3/scene'); + assert.equal(data.getSceneInfo().getTimestamp(), 1234); + assert.equal(data.getObjectCount(), 1); + + const object = data.getObjects()[0]; + assert.equal(object.getUUID(), '00112233-4455-6677-8899-aabbccddeeff'); + assert.equal(object.getID(), 77); + assert.equal(object.getPointCloud().getPointCount(), 2); + assert.deepEqual( + object.getCluster().getVelocity().map((value) => Number(value.toFixed(3))), + [0.4, -0.5, 0.6] + ); +}); + +test('Large point clouds parse without per-coordinate allocation hazards', () => { + const pointCount = 100_000; + const client = new Client(); + client.initialize('large-cloud', { version: 2, useCompression: false }); + + const object = client.parseDataBlob(pointCloudObjectPacket(pointCount)).getObjects()[0]; + const cloud = object.getPointCloud(); + assert.equal(cloud.getPointCount(), pointCount); + assert.deepEqual(cloud.getPoint(0), [1, 2, 3]); + assert.deepEqual(cloud.getPoint(pointCount - 1), [-1, -2, -3]); +}); + +test('Malformed point counts are rejected before allocating their declared payload', () => { + const propertyPayload = i32(2_000_000_000); + const property = concat(i32(8 + propertyPayload.length), i32(0), propertyPayload); + const malformed = packet(0, concat(i32(7), i32(1), property)); + + const client = new Client(); + client.initialize('malformed-cloud', { version: 2, useCompression: false }); + assert.throws( + () => client.parseDataBlob(malformed), + /point cloud coordinates/ + ); +}); + +test('Nested packet sizes cannot escape their enclosing bundle boundary', () => { + const child = packet(99, new Uint8Array()); + const malformedBundle = concat( + i32(9), + u8(255), + i32(1), + child + ); + + const client = new Client(); + client.initialize('nested-boundary', { version: 2, useCompression: false }); + assert.throws( + () => client.parseDataBlob(malformedBundle), + /Malformed Augmenta packet/ + ); +}); + +test('Unknown packet families are skipped by their declared packet size', () => { + const unknown = packet(99, Uint8Array.of(1, 2, 3, 4)); + const knownScene = scenePacket('/known/scene'); + const bundle = packet(255, concat(i32(2), unknown, knownScene)); + + const client = new Client(); + client.initialize('future-packet', { version: 2, useCompression: false }); + const data = client.parseDataBlob(bundle); + assert.equal(data.getSceneInfo().getAddress(), '/known/scene'); +}); + +test('WebSocket convenience ignores delayed events from a replaced socket', () => { + class FakeSocket { + readyState = 0; + binaryType = ''; + sent = []; + listeners = new Map(); + send(data) { this.sent.push(data); } + close() { this.readyState = 3; } + addEventListener(type, listener) { + const list = this.listeners.get(type) ?? []; + list.push(listener); + this.listeners.set(type, list); + } + emit(type, event) { + for (const listener of this.listeners.get(type) ?? []) listener(event); + } + } + + const sockets = []; + const augmenta = new AugmentaWebSocketClient('ws://localhost', { + webSocketFactory: () => { + const socket = new FakeSocket(); + sockets.push(socket); + return socket; + } + }); + + let setups = 0; + augmenta.on('setup', () => { setups++; }); + + augmenta.connect(); + const first = sockets[0]; + first.readyState = 1; + first.emit('open', {}); + + augmenta.disconnect(); + augmenta.connect(); + const second = sockets[1]; + second.readyState = 1; + second.emit('open', {}); + + first.emit('close', {}); + first.emit('message', { + data: JSON.stringify({ status: 'ok', version: 2, setup: { world: { name: 'stale' } } }) + }); + + assert.equal(augmenta.getSocket(), second); + assert.equal(setups, 0); + + second.emit('message', { + data: JSON.stringify({ status: 'ok', version: 2, setup: { world: { name: 'current' } } }) + }); + assert.equal(setups, 1); +}); + +test('poll requires an open WebSocket', () => { + class FakeSocket { + readyState = 0; + sent = []; + listeners = new Map(); + send(data) { this.sent.push(data); } + close() {} + addEventListener(type, listener) { + const list = this.listeners.get(type) ?? []; + list.push(listener); + this.listeners.set(type, list); + } + emit(type, event) { + for (const listener of this.listeners.get(type) ?? []) listener(event); + } + } + + const socket = new FakeSocket(); + const augmenta = new AugmentaWebSocketClient('ws://localhost', { + options: { usePolling: true }, + webSocketFactory: () => socket + }); + + augmenta.connect(); + assert.throws(() => augmenta.poll(), /not open/); + + socket.readyState = 1; + socket.emit('open', {}); + augmenta.poll(); + assert.deepEqual(JSON.parse(socket.sent.at(-1)), { poll: true }); +});