@foxglove/ws-protocol-examples
Version: 
Foxglove WebSocket protocol examples
262 lines (239 loc) • 8.81 kB
text/typescript
import decompressLZ4 from "@foxglove/wasm-lz4";
import * as Zstd from "@foxglove/wasm-zstd";
import { FoxgloveServer } from "@foxglove/ws-protocol";
import { McapIndexedReader, McapTypes } from "@mcap/core";
import { Command } from "commander";
import Debug from "debug";
import fs from "fs/promises";
import { WebSocketServer } from "ws";
import boxen from "../boxen";
import { setupSigintHandler } from "./util/setupSigintHandler";
const log = Debug("foxglove:mcap-play");
Debug.enable("foxglove:*");
// eslint-disable-next-line @typescript-eslint/promise-function-async
function delay(durationMs: number) {
  return new Promise((resolve) => setTimeout(resolve, durationMs));
}
let cachedDecompressHandlers: McapTypes.DecompressHandlers | undefined;
async function getDecompressHandlers(): Promise<McapTypes.DecompressHandlers> {
  if (cachedDecompressHandlers) {
    return cachedDecompressHandlers;
  }
  await decompressLZ4.isLoaded;
  await Zstd.isLoaded;
  cachedDecompressHandlers = {
    lz4: (buffer, decompressedSize) => decompressLZ4(buffer, Number(decompressedSize)),
    zstd: (buffer, decompressedSize) => Zstd.decompress(buffer, Number(decompressedSize)),
  };
  return cachedDecompressHandlers;
}
function readableFromFileHandle(handle: fs.FileHandle): McapTypes.IReadable {
  let buffer = new ArrayBuffer(4096);
  return {
    async size() {
      return BigInt((await handle.stat()).size);
    },
    async read(offset, length) {
      if (offset > Number.MAX_SAFE_INTEGER || length > Number.MAX_SAFE_INTEGER) {
        throw new Error(`Read too large: offset ${offset}, length ${length}`);
      }
      if (length > buffer.byteLength) {
        buffer = new ArrayBuffer(Number(length * 2n));
      }
      const result = await handle.read({
        buffer: new DataView(buffer, 0, Number(length)),
        position: Number(offset),
      });
      if (result.bytesRead !== Number(length)) {
        throw new Error(
          `Read only ${result.bytesRead} bytes from offset ${offset}, expected ${length}`,
        );
      }
      return new Uint8Array(result.buffer.buffer, result.buffer.byteOffset, result.bytesRead);
    },
  };
}
async function* readMcapFile(filePath: string): AsyncIterable<McapTypes.TypedMcapRecord> {
  const decompressHandlers = await getDecompressHandlers();
  const handle = await fs.open(filePath, "r");
  try {
    const reader = await McapIndexedReader.Initialize({
      readable: readableFromFileHandle(handle),
      decompressHandlers,
    });
    for (const schema of reader.schemasById.values()) {
      yield schema;
    }
    for (const channel of reader.channelsById.values()) {
      yield channel;
    }
    for await (const message of reader.readMessages()) {
      yield message;
    }
  } catch (err) {
    throw new Error(`Unable to read file as indexed: ${(err as Error).toString()}`);
  } finally {
    await handle.close();
  }
}
type McapChannelId = number & { __brand: "McapChannelId" };
type WsChannelId = number & { __brand: "WsChannelId" };
async function main(file: string, options: { loop: boolean; rate: number }): Promise<void> {
  const server = new FoxgloveServer({ name: file });
  const port = 8765;
  const ws = new WebSocketServer({
    port,
    handleProtocols: (protocols) => server.handleProtocols(protocols),
  });
  const signal = setupSigintHandler(log, ws);
  const schemasById = new Map<number, McapTypes.Schema>();
  const mcapChannelsByWsChannel = new Map<WsChannelId, McapChannelId>();
  const wsChannelsByMcapChannel = new Map<McapChannelId, WsChannelId>();
  const subscribedChannels = new Set<WsChannelId>();
  const skippedChannelIds = new Set<McapChannelId>();
  let running = false;
  const runLoop = async () => {
    let firstIteration = true;
    outer: do {
      log("starting playback");
      let startTime: number | undefined;
      let firstMessageTime: bigint | undefined;
      for await (const record of readMcapFile(file)) {
        if (!running || signal.aborted) {
          break outer;
        }
        // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check
        switch (record.type) {
          case "Schema":
            if (!firstIteration) {
              break;
            }
            schemasById.set(record.id, record);
            break;
          case "Channel": {
            if (!firstIteration) {
              break;
            }
            const schema = schemasById.get(record.schemaId);
            if (!schema) {
              log("Channel %d has unknown schema %d", record.id, record.schemaId);
              break;
            }
            let schemaData: string;
            switch (schema.encoding) {
              case "ros1msg":
              case "ros2msg":
              case "ros2idl":
              case "omgidl":
              case "jsonschema":
                schemaData = new TextDecoder().decode(schema.data);
                break;
              case "protobuf":
              case "flatbuffer":
                schemaData = Buffer.from(schema.data).toString("base64");
                break;
              default:
                log(
                  "Unknown schema encoding %s for channel %d, skipping",
                  schema.encoding,
                  record.id,
                );
                skippedChannelIds.add(record.id as McapChannelId);
                continue;
            }
            const wsChannelId = server.addChannel({
              topic: record.topic,
              schemaName: schema.name,
              encoding: record.messageEncoding,
              schemaEncoding: schema.encoding,
              schema: schemaData,
            }) as WsChannelId;
            mcapChannelsByWsChannel.set(wsChannelId, record.id as McapChannelId);
            wsChannelsByMcapChannel.set(record.id as McapChannelId, wsChannelId);
            break;
          }
          case "Message": {
            const wsChannelId = wsChannelsByMcapChannel.get(record.channelId as McapChannelId);
            if (wsChannelId == undefined) {
              if (!skippedChannelIds.has(record.channelId as McapChannelId)) {
                log("Message on unknown channel %d", record.channelId);
              }
              break;
            }
            if (firstMessageTime == undefined || startTime == undefined) {
              startTime = performance.now();
              firstMessageTime = record.logTime;
            }
            // Time from the first message to this message
            const elapsedMessageTimeMs = Number(record.logTime - firstMessageTime) / 1_000_000;
            // Wall time from start until now
            const elapsedWallTimeMs = performance.now() - startTime;
            const timeToWaitMs = (elapsedMessageTimeMs - elapsedWallTimeMs) / options.rate;
            if (timeToWaitMs > 0) {
              await delay(timeToWaitMs);
            }
            if (subscribedChannels.has(wsChannelId)) {
              server.sendMessage(wsChannelId, record.logTime, record.data);
            }
            break;
          }
          default:
            log("Unexpected record type %s", record.type);
            break;
        }
      }
      firstIteration = false;
    } while (options.loop);
    log("done!");
    process.exit(0);
  };
  ws.on("listening", () => {
    void boxen(
      `📡 Server listening on localhost:${port}. To see data, visit:\n` +
        `https://app.foxglove.dev/~/view?ds=foxglove-websocket&ds.url=ws://localhost:${port}/`,
      { borderStyle: "round", padding: 1 },
    )
      .then(log)
      .then(() => {
        log("Waiting for client connection...");
      });
  });
  ws.on("connection", (conn, req) => {
    const name = `${req.socket.remoteAddress!}:${req.socket.remotePort!}`;
    log("connection from %s via %s", name, req.url);
    server.handleConnection(conn, name);
    if (!running) {
      running = true;
      void runLoop();
    }
    conn.on("close", () => {
      log("client %s disconnected");
    });
  });
  server.on("subscribe", (chanId) => {
    subscribedChannels.add(chanId as WsChannelId);
  });
  server.on("unsubscribe", (chanId) => {
    subscribedChannels.delete(chanId as WsChannelId);
  });
  server.on("error", (err) => {
    log("server error: %o", err);
  });
}
export default new Command("mcap-play")
  .description("play an MCAP file over a WebSocket client in real time")
  .argument("<file>", "path to MCAP file")
  .option("--loop", "automatically restart playback from the beginning", false)
  .option(
    "--rate <rate>",
    "playback rate as a multiple of realtime",
    (val) => {
      const result = parseFloat(val);
      if (result <= 0 || !isFinite(result)) {
        throw new Error(`Invalid rate: ${val}`);
      }
      return result;
    },
    1,
  )
  .action(main);