Improve web usb and web serial robustness.

This commit is contained in:
raldone01
2025-07-24 23:58:54 +02:00
parent 4cb4fb2e28
commit 30d678970e
3 changed files with 197 additions and 115 deletions

View File

@@ -383,7 +383,14 @@
uiConnectSerialBtn.style.display = 'none'; uiConnectSerialBtn.style.display = 'none';
uiDisconnectBtn.style.display = 'block'; uiDisconnectBtn.style.display = 'block';
uiCommandLineInput.disabled = false; uiCommandLineInput.disabled = false;
uiCommandLineInput.focus();
if (this.currentPort instanceof SerialPort) {
uiDisconnectBtn.textContent = 'Disconnect from WebSerial';
} else if (this.currentPort instanceof WebUsbSerialPort) {
uiDisconnectBtn.textContent = 'Disconnect from WebUSB';
} else {
uiDisconnectBtn.textContent = 'Disconnect';
}
} else { } else {
if (serial.isWebUsbSupported()) { if (serial.isWebUsbSupported()) {
uiConnectWebUsbSerialBtn.style.display = 'block'; uiConnectWebUsbSerialBtn.style.display = 'block';
@@ -454,6 +461,8 @@
await this.currentPort.forgetDevice(); await this.currentPort.forgetDevice();
this.currentPort = null; this.currentPort = null;
} }
} finally {
this.updateUIConnectionState();
} }
} }
@@ -474,7 +483,7 @@
const savedPortInfo = JSON.parse(localStorage.getItem('webUSBSerialPort')); const savedPortInfo = JSON.parse(localStorage.getItem('webUSBSerialPort'));
if (savedPortInfo) { if (savedPortInfo) {
for (const device of grantedDevices) { for (const device of grantedDevices) {
if (device.device.vendorId === savedPortInfo.vendorId && device.device.productId === savedPortInfo.productId) { if (device._device.vendorId === savedPortInfo.vendorId && device._device.productId === savedPortInfo.productId) {
this.currentPort = device; this.currentPort = device;
break; break;
} }
@@ -501,12 +510,13 @@
// save the port to localStorage // save the port to localStorage
const portInfo = { const portInfo = {
vendorId: this.currentPort.device.vendorId, vendorId: this.currentPort._device.vendorId,
productId: this.currentPort.device.productId, productId: this.currentPort._device.productId,
} }
localStorage.setItem('webUSBSerialPort', JSON.stringify(portInfo)); localStorage.setItem('webUSBSerialPort', JSON.stringify(portInfo));
this.setStatus('Connected', 'info'); this.setStatus('Connected', 'info');
uiCommandLineInput.focus();
} catch (error) { } catch (error) {
if (first_time_connection) { if (first_time_connection) {
// Forget the device if a first time connection fails // Forget the device if a first time connection fails
@@ -514,6 +524,8 @@
this.currentPort = null; this.currentPort = null;
} }
throw error; throw error;
} finally {
this.updateUIConnectionState();
} }
this.updateUIConnectionState(); this.updateUIConnectionState();
@@ -530,6 +542,8 @@
this.setStatus('Reconnected', 'info'); this.setStatus('Reconnected', 'info');
} catch (error) { } catch (error) {
this.setStatus(`Reconnect failed: ${error.message}`, 'error'); this.setStatus(`Reconnect failed: ${error.message}`, 'error');
} finally {
this.updateUIConnectionState();
} }
} }
this.updateUIConnectionState(); this.updateUIConnectionState();

View File

@@ -1,80 +1,117 @@
'use strict'; 'use strict';
/// Web Serial API Implementation /// Web Serial API Implementation
/// https://developer.mozilla.org/en-US/docs/Web/API/SerialPort
class SerialPort { class SerialPort {
constructor(port) { constructor(port) {
this.port = port; this._port = port;
this.reader = null; this._readLoopPromise = null;
this.writer = null; this._reader = null;
this.readableStreamClosed = null; this._writer = null;
this._initialized = false;
this._keepReading = true;
this.isConnected = false; this.isConnected = false;
this.readLoop = null;
this.initialized = false;
} }
/// Connect and start reading loop /// Connect and start reading loop
async connect(options = { baudRate: 9600 }) { async connect(options = { baudRate: 9600 }) {
if (this.initialized) { if (this._initialized) {
return; try {
await this.disconnect();
} catch (error) {
console.error('Error disconnecting previous port:', error);
}
await this._readLoopPromise;
this._readLoopPromise = null;
} }
this.initialized = true; this._initialized = true;
await this.port.open(options);
this.readableStreamClosed = this.port.readable;
this.reader = this.port.readable.getReader();
this.writer = this.port.writable.getWriter();
this.isConnected = true; this.isConnected = true;
this.readLoop = this._readLoop(); this._keepReading = true;
try {
await this._port.open(options);
} catch (error) {
this.isConnected = false;
throw error;
}
this._readLoopPromise = this._readLoop();
} }
/// Internal continuous read loop /// Internal continuous read loop
async _readLoop() { async _readLoop() {
while (this.isConnected) { try {
try { while (this._port.readable && this._keepReading) {
const { value, done } = await this.reader.read(); this._reader = this._port.readable.getReader();
if (done || !this.isConnected) break; try {
if (value && this.onReceive) this.onReceive(value); while (true) {
} catch (error) { const { value, done } = await this._reader.read();
this.isConnected = false; if (done) {
if (this.onReceiveError) this.onReceiveError(error); // |reader| has been canceled.
break;
}
if (this.onReceive) {
this.onReceive(value);
}
}
} catch (error) {
if (this.onReceiveError) this.onReceiveError(error);
} finally {
this._reader.releaseLock();
}
} }
} finally {
this.isConnected = false;
await this._port.close();
} }
} }
/// Stop reading and release port /// Stop reading and release port
async disconnect() { async disconnect() {
this.isConnected = false; this._keepReading = false;
if (this.reader) { if (this._reader) {
try { try {
await this.reader.cancel(); await this._reader.cancel();
} catch (error) {} } catch (error) {
this.reader.releaseLock(); console.error('Error cancelling reader:', error);
}
this._reader.releaseLock();
} }
if (this.writer) { if (this._writer) {
try { try {
await this.writer.close(); await this._writer.abort();
} catch (error) { } } catch (error) {
this.writer.releaseLock(); console.error('Error closing writer:', error);
} }
this._writer.releaseLock();
if (this.readableStreamClosed) {
try {
await this.readableStreamClosed;
} catch (error) {}
} }
try { try {
await this.port.close(); await this._port.close();
} catch (error) {} } catch (error) {
console.error('Error closing port:', error);
}
await this._readLoopPromise;
} }
/// Send data to port /// Send data to port
send(data) { send(data) {
if (!this.writer) throw new Error('Port not connected'); if (!this._port.writable) {
return this.writer.write(data); throw new Error('Port is not writable');
}
this._writer = port.writeable.getWriter();
if (!this._writer) {
throw new Error('Failed to get writer from port');
}
try {
return this._writer.write(data);
} finally {
this._writer.releaseLock();
}
} }
async forgetDevice() {} async forgetDevice() {}
@@ -83,115 +120,125 @@ class SerialPort {
/// WebUSB Implementation /// WebUSB Implementation
class WebUsbSerialPort { class WebUsbSerialPort {
constructor(device) { constructor(device) {
this.device = device; this._device = device;
this.interfaceNumber = 0; this._interfaceNumber = 0;
this.endpointIn = 0; this._endpointIn = 0;
this.endpointOut = 0; this._endpointOut = 0;
this.isConnected = false; this.isConnected = false;
this.readLoop = null; this._readLoopPromise = null;
this.initialized = false; this._initialized = false;
} this._keepReading = true;
isSameDevice(device) {
return this.device.vendorId === device.vendorId && this.device.productId === device.productId;
} }
/// Connect and start reading loop /// Connect and start reading loop
async connect() { async connect() {
if (this.initialized) { if (this._initialized) {
const devices = await serial.getWebUsbSerialPorts(); try {
const device = devices.find(d => this.isSameDevice(d.device)); await this.disconnect();
if (device) { } catch (error) {
this.device = device.device; console.error('Error disconnecting previous device:', error);
} else {
return false;
} }
await this.device.open(); await this._readLoopPromise;
this._readLoopPromise = null;
} }
this.initialized = true; this._initialized = true;
await this.device.open();
this.isConnected = true;
this._keepReading = true;
try { try {
await this.device.reset(); await this._device.open();
} catch (error) { }
if (!this.device.configuration) { if (!this._device.configuration) {
await this.device.selectConfiguration(1); await this._device.selectConfiguration(1);
} }
// Find interface with vendor-specific class (0xFF) and endpoints // Find interface with vendor-specific class (0xFF) and endpoints
for (const iface of this.device.configuration.interfaces) { for (const iface of this._device.configuration.interfaces) {
for (const alternate of iface.alternates) { for (const alternate of iface.alternates) {
if (alternate.interfaceClass === 0xff) { if (alternate.interfaceClass === 0xff) {
this.interfaceNumber = iface.interfaceNumber; this._interfaceNumber = iface.interfaceNumber;
for (const endpoint of alternate.endpoints) { for (const endpoint of alternate.endpoints) {
if (endpoint.direction === 'out') this.endpointOut = endpoint.endpointNumber; if (endpoint.direction === 'out') this._endpointOut = endpoint.endpointNumber;
else if (endpoint.direction === 'in') this.endpointIn = endpoint.endpointNumber; else if (endpoint.direction === 'in') this._endpointIn = endpoint.endpointNumber;
}
} }
} }
} }
if (this._interfaceNumber === undefined) {
throw new Error('No suitable interface found.');
}
await this._device.claimInterface(this._interfaceNumber);
await this._device.selectAlternateInterface(this._interfaceNumber, 0);
// Set device to ENABLE (0x22 = SET_CONTROL_LINE_STATE, value 0x01 = activate)
await this._device.controlTransferOut({
requestType: 'class',
recipient: 'interface',
request: 0x22,
value: 0x01,
index: this._interfaceNumber,
});
} catch (error) {
this.isConnected = false;
throw error;
} }
if (this.interfaceNumber === undefined) { this._readLoopPromise = this._readLoop();
throw new Error('No suitable interface found.');
}
await this.device.claimInterface(this.interfaceNumber);
await this.device.selectAlternateInterface(this.interfaceNumber, 0);
// Set device to ENABLE (0x22 = SET_CONTROL_LINE_STATE, value 0x01 = activate)
await this.device.controlTransferOut({
requestType: 'class',
recipient: 'interface',
request: 0x22,
value: 0x01,
index: this.interfaceNumber,
});
this.isConnected = true;
this.readLoop = this._readLoop();
} }
/// Internal continuous read loop /// Internal continuous read loop
async _readLoop() { async _readLoop() {
while (this.isConnected) { try {
try { while (this._keepReading && this.isConnected) {
const result = await this.device.transferIn(this.endpointIn, 16384); try {
if (result.data && this.onReceive) { const result = await this._device.transferIn(this._endpointIn, 16384);
this.onReceive(result.data); if (result.data && this.onReceive) {
} this.onReceive(result.data);
} catch (error) { }
this.isConnected = false; } catch (error) {
if (this.onReceiveError) { this.isConnected = false;
this.onReceiveError(error); if (this.onReceiveError) {
this.onReceiveError(error);
}
} }
} }
} finally {
this.isConnected = false;
await this._device.close();
} }
} }
/// Stop reading and release device /// Stop reading and release device
async disconnect() { async disconnect() {
this.isConnected = false; this._keepReading = false;
if (!this.device.opened) return;
try { try {
await this.device.controlTransferOut({ await this._device.controlTransferOut({
requestType: 'class', requestType: 'class',
recipient: 'interface', recipient: 'interface',
request: 0x22, request: 0x22,
value: 0x00, value: 0x00,
index: this.interfaceNumber, index: this._interfaceNumber,
}); });
} catch (error) {} } catch (error) {
await this.device.close(); console.error('Error sending control transfer:', error);
}
await this._device.releaseInterface(this._interfaceNumber);
await this._readLoopPromise;
} }
/// Send data to device /// Send data to device
send(data) { send(data) {
return this.device.transferOut(this.endpointOut, data); return this._device.transferOut(this._endpointOut, data);
} }
async forgetDevice() { async forgetDevice() {
await this.disconnect(); await this.disconnect();
await this.device.forget(); await this._device.forget();
} }
} }

View File

@@ -270,3 +270,24 @@ body.dark-mode .send-mode-command {
background-color: #555; background-color: #555;
color: #f5f5f5; color: #f5f5f5;
} }
body.dark-mode select {
background-color: #3c3c3c;
color: #f0f0f0;
border: 2px solid #555;
}
body.dark-mode select:focus {
background-color: #2a2d2e;
border-color: #0078d7;
outline: none;
}
body.dark-mode option {
background-color: #3c3c3c;
color: #f0f0f0;
}
body.dark-mode .scrollbox {
scrollbar-color: #555 #2e2e2e;
}