diff --git a/internal/licenses/manifest.go b/internal/licenses/manifest.go index d075892..b1e5ad3 100644 --- a/internal/licenses/manifest.go +++ b/internal/licenses/manifest.go @@ -269,6 +269,7 @@ var FirstPartyStatic = []string{ "listfilter.js", "meshmap.js", "regionmap.js", + "serial-port.js", "serial-setup.js", "timezone-picker.js", "ui.js", diff --git a/internal/web/static/serial-port.js b/internal/web/static/serial-port.js new file mode 100644 index 0000000..c81093c --- /dev/null +++ b/internal/web/static/serial-port.js @@ -0,0 +1,245 @@ +// One way to open a serial port, whatever the browser offers. +// +// Both serial callers (serial-setup.js talking the repeater CLI, console.js +// bridging a KISS modem) need exactly four things from a port: open it at a baud +// rate, read bytes, write bytes, close it. This module supplies that as +// window.MeshSerial, backed by whichever transport is actually available: +// +// Web Serial (navigator.serial) — Chrome/Edge on desktop, Firefox 151+ on +// desktop. Used verbatim when present; MeshSerial just forwards to it. +// +// WebUSB (navigator.usb) — the fallback that makes Android work. Chrome for +// Android has had WebUSB for years, while Web Serial only reached Android in +// Chrome M149 and its USB half depends on a platform API that shipped to a +// limited set of devices. So on a phone, WebUSB is the transport that exists. +// +// The WebUSB path speaks USB CDC-ACM — the standard "USB serial" device class — +// by claiming the interfaces itself and driving the two class requests that +// configure the line. Constants and the open sequence are from the USB CDC +// specification v1.1 (§6.2.12 Set_Line_Coding, §6.2.14 Set_Control_Line_State). +// +// IMPORTANT — what this does NOT cover: boards whose USB is a vendor-specific +// bridge chip (CP210x, CH340, FTDI) are not CDC-ACM. They enumerate with a +// vendor interface class and speak a proprietary control protocol per chip, so +// requestPort() will not offer them. MCUs with native USB (nRF52840, ESP32-S3/C3) +// are CDC-ACM and work. A CP210x driver is a separate, additive change: it plugs +// in as another transport behind this same facade. + +(function () { + "use strict"; + + // USB CDC interface classes. The control interface carries the class requests; + // the data interface carries the bulk endpoints that move bytes. + const CDC_CONTROL_CLASS = 0x02; + const CDC_DATA_CLASS = 0x0a; + + // USB CDC class requests (spec v1.1 §6.2). + const REQ_SET_LINE_CODING = 0x20; + const REQ_SET_CONTROL_LINE_STATE = 0x22; + + const hasWebSerial = typeof navigator !== "undefined" && "serial" in navigator; + const hasWebUSB = typeof navigator !== "undefined" && "usb" in navigator; + + // findInterface returns the first interface whose (first alternate) class is + // `cls`, or null. Called after the configuration is selected, so + // device.configuration is populated. + function findInterface(device, cls) { + const config = device.configuration; + if (!config) return null; + for (const iface of config.interfaces) { + const alt = iface.alternates[0]; + if (alt && alt.interfaceClass === cls) return iface; + } + return null; + } + + // findEndpoint returns the first bulk endpoint on `iface` in `direction` + // ("in" or "out"), or null. + function findEndpoint(iface, direction) { + const alt = iface.alternates[0]; + if (!alt) return null; + for (const ep of alt.endpoints) { + if (ep.direction === direction && ep.type === "bulk") return ep; + } + return null; + } + + // CdcAcmPort presents the subset of the Web Serial SerialPort interface our + // callers use — open(), readable, writable, close() — on top of a USBDevice. + class CdcAcmPort { + constructor(device) { + this.device_ = device; + this.control_ = null; + this.data_ = null; + this.in_ = null; + this.out_ = null; + this.readable = null; + this.writable = null; + // Set before we tear the device down, so a transfer that was already in + // flight can fail quietly instead of surfacing as a read error. + this.closing_ = false; + } + + async open(options) { + const baudRate = (options && options.baudRate) || 115200; + await this.device_.open(); + if (this.device_.configuration === null) { + // configurations[0] rather than a hardcoded 1: the first configuration + // is the one we searched for interfaces, whatever its value. + await this.device_.selectConfiguration(this.device_.configurations[0].configurationValue); + } + + this.control_ = findInterface(this.device_, CDC_CONTROL_CLASS); + this.data_ = findInterface(this.device_, CDC_DATA_CLASS); + if (!this.control_ || !this.data_) { + await this.abandon_(); + throw new Error( + "This USB device isn't a standard serial (CDC-ACM) device. Boards with a " + + "CP210x, CH340, or FTDI bridge chip aren't supported over WebUSB yet." + ); + } + this.in_ = findEndpoint(this.data_, "in"); + this.out_ = findEndpoint(this.data_, "out"); + if (!this.in_ || !this.out_) { + await this.abandon_(); + throw new Error("This USB device has no usable serial data endpoints."); + } + + try { + await this.device_.claimInterface(this.control_.interfaceNumber); + if (this.data_.interfaceNumber !== this.control_.interfaceNumber) { + await this.device_.claimInterface(this.data_.interfaceNumber); + } + await this.setLineCoding_(baudRate); + await this.setControlLineState_(true); + } catch (e) { + await this.abandon_(); + // A claim failure almost always means the OS driver owns the device, + // which is the common desktop-Linux case and worth naming. + throw new Error("Could not take control of the USB device: " + e.message); + } + + this.readable = new ReadableStream(this.source_(), { highWaterMark: 0 }); + this.writable = new WritableStream(this.sink_()); + } + + // setLineCoding_ configures the line as 8-N-1 at `baudRate`. Devices with + // native USB ignore the values (there is no real UART behind them), but the + // request is part of bringing an ACM device up, so we always send it. + // Payload layout is spec v1.1 §6.2.12: dwDTERate, bCharFormat, bParityType, + // bDataBits. + async setLineCoding_(baudRate) { + const buf = new ArrayBuffer(7); + const view = new DataView(buf); + view.setUint32(0, baudRate, true); // little-endian + view.setUint8(4, 0); // 1 stop bit + view.setUint8(5, 0); // no parity + view.setUint8(6, 8); // 8 data bits + const result = await this.device_.controlTransferOut({ + requestType: "class", + recipient: "interface", + request: REQ_SET_LINE_CODING, + value: 0, + index: this.control_.interfaceNumber, + }, buf); + if (result.status !== "ok") throw new Error("failed to set line coding (" + result.status + ")"); + } + + // setControlLineState_ raises or drops DTR. Asserting DTR is what tells a + // CDC device the host is present; without it many firmwares never start + // sending. Bitmap is spec v1.1 §6.2.14: bit 0 DTR, bit 1 RTS. + async setControlLineState_(on) { + await this.device_.controlTransferOut({ + requestType: "class", + recipient: "interface", + request: REQ_SET_CONTROL_LINE_STATE, + value: on ? 0x01 : 0x00, + index: this.control_.interfaceNumber, + }); + } + + // source_ reads one USB packet per pull. Reading a single packet (rather + // than a multi-packet buffer) keeps latency predictable for the KISS bridge + // and avoids a transfer that sits unfinished waiting to be filled. + source_() { + const port = this; + return { + async pull(controller) { + try { + const result = await port.device_.transferIn(port.in_.endpointNumber, port.in_.packetSize); + if (port.closing_) return; + if (result.status !== "ok") { + controller.error(new Error("USB read failed (" + result.status + ")")); + return; + } + if (result.data && result.data.byteLength) { + controller.enqueue(new Uint8Array( + result.data.buffer, result.data.byteOffset, result.data.byteLength)); + } + } catch (e) { + if (port.closing_) return; // we pulled the device out from under it + controller.error(e); + } + }, + }; + } + + sink_() { + const port = this; + return { + async write(chunk, controller) { + try { + const result = await port.device_.transferOut(port.out_.endpointNumber, chunk); + if (result.status !== "ok") { + controller.error(new Error("USB write failed (" + result.status + ")")); + } + } catch (e) { + controller.error(e); + } + }, + }; + } + + // abandon_ closes the device after a failed open, without the stream + // teardown close() does — there are no streams yet at that point. + async abandon_() { + try { if (this.device_.opened) await this.device_.close(); } catch (_) {} + } + + async close() { + this.closing_ = true; + // Callers release their reader/writer locks before close(), but cancel() + // and abort() throw on a still-locked stream, so neither is load-bearing. + try { if (this.readable) await this.readable.cancel(); } catch (_) {} + try { if (this.writable) await this.writable.abort(); } catch (_) {} + this.readable = null; + this.writable = null; + if (this.device_.opened) { + try { await this.setControlLineState_(false); } catch (_) {} + try { await this.device_.close(); } catch (_) {} + } + } + } + + // requestPort prompts the user to pick a device and returns a port. Must be + // called from a user gesture, same as navigator.serial.requestPort(). + async function requestPort() { + if (hasWebSerial) return navigator.serial.requestPort(); + // Filtering on the CDC control class keeps the chooser to devices we can + // actually drive, rather than listing every USB device attached. + const device = await navigator.usb.requestDevice({ + filters: [{ classCode: CDC_CONTROL_CLASS }], + }); + return new CdcAcmPort(device); + } + + window.MeshSerial = { + // supported answers "can we even ask for a port here?" — not "will this + // particular board work", which is only knowable once one is chosen. + supported: hasWebSerial || hasWebUSB, + // transport lets callers tailor their messaging: the ways this fails differ + // between the two (no port vs. a device we can't drive). + transport: hasWebSerial ? "webserial" : (hasWebUSB ? "webusb" : null), + requestPort: requestPort, + }; +})();