Improve web usb and web serial robustness.
This commit is contained in:
@@ -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();
|
||||||
|
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -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;
|
||||||
|
}
|
||||||
|
Reference in New Issue
Block a user