CI Log in

zach/dotter

src/serial.js 108 lines · 3.3 KB · javascript
1
// WebSerial transport for the EBB (USB CDC serial; Chrome/Edge/Opera).
2
3
// EiBotBoard USB IDs (Microchip VID, Evil Mad Scientist PID).
4
export const EBB_USB_FILTER = { usbVendorId: 0x04d8, usbProductId: 0xfd92 };
5
6
export function webSerialSupported() {
7
  return typeof navigator !== 'undefined' && 'serial' in navigator;
8
}
9
10
export class WebSerialTransport {
11
  constructor(port) {
12
    this.port = port;
13
    this.reader = null;
14
    this.writer = null;
15
    this.buffer = '';
16
    this.decoder = new TextDecoder();
17
    this.encoder = new TextEncoder();
18
    this.pending = null; // In-flight reader.read() promise, kept across timeouts
19
  }
20
21
  /** Prompt the user to pick a NextDraw. Must be called from a user gesture. */
22
  static async request() {
23
    const port = await navigator.serial.requestPort({ filters: [EBB_USB_FILTER] });
24
    return new WebSerialTransport(port);
25
  }
26
27
  /** Previously-authorized NextDraws, available without a prompt. */
28
  static async authorized() {
29
    const ports = await navigator.serial.getPorts();
30
    return ports
31
      .filter((p) => {
32
        const info = p.getInfo();
33
        return info.usbVendorId === EBB_USB_FILTER.usbVendorId && info.usbProductId === EBB_USB_FILTER.usbProductId;
34
      })
35
      .map((p) => new WebSerialTransport(p));
36
  }
37
38
  async open() {
39
    if (!this.port.readable) await this.port.open({ baudRate: 115200 });
40
    this.reader = this.port.readable.getReader();
41
    this.writer = this.port.writable.getWriter();
42
  }
43
44
  async close() {
45
    try {
46
      if (this.reader) {
47
        await this.reader.cancel().catch(() => {});
48
        this.reader.releaseLock();
49
      }
50
      if (this.writer) {
51
        await this.writer.close().catch(() => {});
52
        this.writer.releaseLock();
53
      }
54
    } finally {
55
      this.reader = null;
56
      this.writer = null;
57
      this.pending = null;
58
      await this.port.close().catch(() => {});
59
    }
60
  }
61
62
  async write(text) {
63
    await this.writer.write(this.encoder.encode(text));
64
  }
65
66
  /** Discard anything already received. */
67
  async clearInput() {
68
    this.buffer = '';
69
    // Drain whatever arrives within a short window.
70
    while ((await this.readChunk(20)) != null) this.buffer = '';
71
  }
72
73
  /** Resolve with the next chunk of text, or null after timeoutMs. */
74
  async readChunk(timeoutMs) {
75
    if (!this.pending) {
76
      this.pending = this.reader.read().then((r) => {
77
        this.pending = null;
78
        if (r.done) throw new Error('Serial port closed');
79
        return this.decoder.decode(r.value, { stream: true });
80
      });
81
    }
82
    let timer;
83
    const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve(null), timeoutMs); });
84
    try {
85
      return await Promise.race([this.pending, timeout]);
86
    } finally {
87
      clearTimeout(timer);
88
    }
89
  }
90
91
  /** Next line without its terminator, or '' on timeout (like pyserial readline). */
92
  async readLine(timeoutMs = 1000) {
93
    const deadline = performance.now() + timeoutMs;
94
    for (;;) {
95
      const nl = this.buffer.indexOf('\n');
96
      if (nl >= 0) {
97
        const line = this.buffer.slice(0, nl + 1);
98
        this.buffer = this.buffer.slice(nl + 1);
99
        return line;
100
      }
101
      const remaining = deadline - performance.now();
102
      if (remaining <= 0) return '';
103
      const chunk = await this.readChunk(remaining);
104
      if (chunk == null) return '';
105
      this.buffer += chunk;
106
    }
107
  }
108
}