Skip to content

Commit 09e40cd

Browse files
authored
Merge pull request #15 from sysdevrun/claude/fix-webserial-communication-bO1Ms
Simplify Web Serial protocol handling and remove unnecessary code
2 parents b3325ad + c0c9ff1 commit 09e40cd

1 file changed

Lines changed: 53 additions & 148 deletions

File tree

src/lib/web-serial.ts

Lines changed: 53 additions & 148 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,9 @@
11
/**
22
* Web Serial API interface for NFC readers in CDC/Serial mode
3-
*
4-
* This is the preferred method for Windows as WebUSB cannot access
5-
* devices claimed by the USB Serial driver (usbser.sys).
6-
*
7-
* ASK RDR-518 Protocol Flow:
8-
* 1. Host sends command
9-
* 2. Reader responds with ACK (0x01) indicating "processing"
10-
* 3. Host sends empty message to poll for result
11-
* 4. Reader responds with actual payload
123
*/
134

14-
// Logging callback type for external logging
155
export type SerialLogCallback = (direction: 'TX' | 'RX' | 'INFO', data: string) => void;
166

17-
// Global log callback - can be set by the application
187
let logCallback: SerialLogCallback | null = null;
198

209
export function setSerialLogCallback(callback: SerialLogCallback | null): void {
@@ -25,7 +14,6 @@ function log(direction: 'TX' | 'RX' | 'INFO', data: string): void {
2514
if (logCallback) {
2615
logCallback(direction, data);
2716
}
28-
// Always log to console for debugging
2917
const prefix = direction === 'TX' ? '→ TX:' : direction === 'RX' ? '← RX:' : 'ℹ INFO:';
3018
console.log(`[Serial] ${prefix} ${data}`);
3119
}
@@ -34,6 +22,7 @@ export interface SerialDevice {
3422
port: SerialPort;
3523
reader: ReadableStreamDefaultReader<Uint8Array> | null;
3624
writer: WritableStreamDefaultWriter<Uint8Array> | null;
25+
pendingRead?: Promise<ReadableStreamReadResult<Uint8Array>>;
3726
}
3827

3928
export interface SerialConnectionResult {
@@ -42,7 +31,6 @@ export interface SerialConnectionResult {
4231
error?: string;
4332
}
4433

45-
// Known NFC reader USB vendor/product IDs for serial filter
4634
export const KNOWN_SERIAL_NFC_READERS = [
4735
{ usbVendorId: 0x1fd3, usbProductId: 0x0108 }, // ASK RDR-518
4836
{ usbVendorId: 0x072f }, // ACS readers
@@ -52,16 +40,10 @@ export const KNOWN_SERIAL_NFC_READERS = [
5240
{ usbVendorId: 0x067b }, // Prolific PL2303
5341
];
5442

55-
/**
56-
* Check if Web Serial API is supported
57-
*/
5843
export function isWebSerialSupported(): boolean {
5944
return 'serial' in navigator;
6045
}
6146

62-
/**
63-
* Request a serial port from the user
64-
*/
6547
export async function requestSerialPort(): Promise<SerialConnectionResult> {
6648
if (!isWebSerialSupported()) {
6749
return {
@@ -74,14 +56,10 @@ export async function requestSerialPort(): Promise<SerialConnectionResult> {
7456
const port = await navigator.serial.requestPort({
7557
filters: KNOWN_SERIAL_NFC_READERS,
7658
});
77-
7859
return await connectSerialPort(port);
7960
} catch (error) {
8061
if (error instanceof DOMException && error.name === 'NotFoundError') {
81-
return {
82-
success: false,
83-
error: 'No serial port selected.',
84-
};
62+
return { success: false, error: 'No serial port selected.' };
8563
}
8664
return {
8765
success: false,
@@ -90,9 +68,6 @@ export async function requestSerialPort(): Promise<SerialConnectionResult> {
9068
}
9169
}
9270

93-
/**
94-
* Connect to a serial port
95-
*/
9671
export async function connectSerialPort(port: SerialPort): Promise<SerialConnectionResult> {
9772
try {
9873
log('INFO', 'Opening serial port at 115200 baud...');
@@ -112,11 +87,7 @@ export async function connectSerialPort(port: SerialPort): Promise<SerialConnect
11287

11388
return {
11489
success: true,
115-
device: {
116-
port,
117-
reader,
118-
writer,
119-
},
90+
device: { port, reader, writer },
12091
};
12192
} catch (error) {
12293
log('INFO', `Connection failed: ${error instanceof Error ? error.message : String(error)}`);
@@ -127,9 +98,6 @@ export async function connectSerialPort(port: SerialPort): Promise<SerialConnect
12798
}
12899
}
129100

130-
/**
131-
* Disconnect from serial port
132-
*/
133101
export async function disconnectSerialPort(device: SerialDevice): Promise<void> {
134102
log('INFO', 'Disconnecting serial port...');
135103
try {
@@ -145,23 +113,11 @@ export async function disconnectSerialPort(device: SerialDevice): Promise<void>
145113
log('INFO', 'Serial port disconnected');
146114
} catch (error) {
147115
log('INFO', `Error disconnecting: ${error instanceof Error ? error.message : String(error)}`);
148-
console.error('Error disconnecting serial port:', error);
149116
}
150117
}
151118

152119
/**
153-
* Send data over serial port
154-
*/
155-
export async function sendSerialData(device: SerialDevice, data: Uint8Array): Promise<void> {
156-
if (!device.writer) {
157-
throw new Error('Serial port writer not available');
158-
}
159-
await device.writer.write(data);
160-
}
161-
162-
/**
163-
* Send command and receive response
164-
* For ASK RDR-518: Response may include ACK prefix (0x01) followed by payload
120+
* Send command and receive response.
165121
*/
166122
export async function transceiveSerial(
167123
device: SerialDevice,
@@ -172,33 +128,42 @@ export async function transceiveSerial(
172128
throw new Error('Serial port not ready');
173129
}
174130

175-
// Clear any stale data in the buffer first
176-
log('INFO', 'Clearing serial buffer...');
177-
await clearSerialBuffer(device);
131+
// Consume any pending read from previous timeout
132+
if (device.pendingRead) {
133+
try {
134+
const stale = await Promise.race([
135+
device.pendingRead,
136+
new Promise<null>(r => setTimeout(() => r(null), 50))
137+
]);
138+
if (stale && stale.value) {
139+
log('RX', `Stale (${stale.value.length} bytes): ${toHexSerial(stale.value)}`);
140+
}
141+
} catch {
142+
// Ignore errors from stale read
143+
}
144+
device.pendingRead = undefined;
145+
}
178146

179-
// Send the command
147+
// Send command
180148
log('TX', `Command (${command.length} bytes): ${toHexSerial(command)}`);
181-
await sendSerialData(device, command);
149+
await device.writer.write(command);
182150

183151
// Read response
184-
log('INFO', 'Reading response...');
185-
const response = await readResponse(device, timeout);
152+
const response = await readWithTimeout(device, timeout);
186153
log('RX', `Response (${response.length} bytes): ${toHexSerial(response)}`);
187154

188-
// Check if response starts with ACK (0x01) followed by payload
155+
// Handle ACK prefix (0x01)
189156
if (response.length > 1 && response[0] === 0x01) {
190-
log('INFO', 'Response includes ACK prefix, extracting payload');
191157
return response.slice(1);
192158
}
193159

194160
return response;
195161
}
196162

197163
/**
198-
* Read response - waits for complete frame with proper timeout handling
199-
* If timeout occurs with pending read, cancels and recreates the reader
164+
* Read with timeout. Tracks pending read to avoid orphaning.
200165
*/
201-
async function readResponse(
166+
async function readWithTimeout(
202167
device: SerialDevice,
203168
timeout: number
204169
): Promise<Uint8Array> {
@@ -209,100 +174,50 @@ async function readResponse(
209174
const chunks: Uint8Array[] = [];
210175
let totalBytes = 0;
211176
const startTime = Date.now();
212-
let timedOut = false;
213177

214-
// Keep reading until we have a complete frame or timeout
215-
while (Date.now() - startTime < timeout && !timedOut) {
216-
// Start a single read
217-
const readPromise = device.reader.read();
178+
while (Date.now() - startTime < timeout) {
179+
const remaining = timeout - (Date.now() - startTime);
180+
if (remaining <= 0) break;
218181

219-
// Race with timeout
220-
const timeoutMs = Math.min(500, timeout - (Date.now() - startTime));
221-
let timeoutId: ReturnType<typeof setTimeout>;
222-
const timeoutPromise = new Promise<'timeout'>((resolve) => {
223-
timeoutId = setTimeout(() => resolve('timeout'), timeoutMs);
224-
});
182+
// Start read
183+
const readPromise = device.reader.read();
184+
const timeoutPromise = new Promise<'timeout'>(resolve =>
185+
setTimeout(() => resolve('timeout'), remaining)
186+
);
225187

226188
const result = await Promise.race([readPromise, timeoutPromise]);
227189

228190
if (result === 'timeout') {
229-
// Timeout - need to cancel the pending read
230-
log('INFO', 'Read timeout, resetting reader...');
231-
timedOut = true;
232-
233-
// Cancel reader to abort pending read, then recreate it
234-
try {
235-
await device.reader.cancel();
236-
device.reader.releaseLock();
237-
device.reader = device.port.readable?.getReader() || null;
238-
} catch (e) {
239-
log('INFO', `Reader reset error: ${e instanceof Error ? e.message : String(e)}`);
240-
}
191+
// Store the pending read for later consumption
192+
device.pendingRead = readPromise;
193+
log('INFO', 'Read timeout');
241194
break;
242195
}
243196

244-
// Clear timeout since read completed
245-
clearTimeout(timeoutId!);
197+
if (result.done) {
198+
log('INFO', 'Stream closed');
199+
break;
200+
}
246201

247202
if (result.value && result.value.length > 0) {
248203
chunks.push(result.value);
249204
totalBytes += result.value.length;
250205
log('RX', `Chunk (${result.value.length} bytes): ${toHexSerial(result.value)}`);
251206

252207
const combined = combineChunks(chunks, totalBytes);
253-
254-
// Return if we have a complete frame or ACK+payload
255-
if (isCompleteFrame(combined) || (combined.length > 1 && combined[0] === 0x01)) {
208+
if (isCompleteFrame(combined)) {
256209
return combined;
257210
}
258-
259-
// Got partial data, continue reading for more
260-
continue;
261-
}
262-
263-
if (result.done) {
264-
log('INFO', 'Reader done');
265-
break;
266211
}
267212
}
268213

269214
return combineChunks(chunks, totalBytes);
270215
}
271216

272-
/**
273-
* Clear any stale data from the serial buffer
274-
*/
275-
async function clearSerialBuffer(device: SerialDevice): Promise<void> {
276-
if (!device.reader) return;
277-
278-
const startTime = Date.now();
279-
let clearedBytes = 0;
280-
while (Date.now() - startTime < 100) {
281-
try {
282-
const readPromise = device.reader.read();
283-
const timeoutPromise = new Promise<{ value: undefined; done: true }>((resolve) =>
284-
setTimeout(() => resolve({ value: undefined, done: true }), 20)
285-
);
286-
const result = await Promise.race([readPromise, timeoutPromise]);
287-
if (result.done || !result.value || result.value.length === 0) {
288-
break; // Buffer is empty
289-
}
290-
// Log discarded stale data
291-
log('RX', `Stale data cleared: ${toHexSerial(result.value)}`);
292-
clearedBytes += result.value.length;
293-
} catch {
294-
break;
295-
}
296-
}
297-
if (clearedBytes > 0) {
298-
log('INFO', `Cleared ${clearedBytes} stale bytes from buffer`);
299-
}
300-
}
301-
302-
/**
303-
* Helper to combine chunks into a single Uint8Array
304-
*/
305217
function combineChunks(chunks: Uint8Array[], totalLength: number): Uint8Array {
218+
if (chunks.length === 0) return new Uint8Array(0);
219+
if (chunks.length === 1) return chunks[0];
220+
306221
const result = new Uint8Array(totalLength);
307222
let offset = 0;
308223
for (const chunk of chunks) {
@@ -313,31 +228,29 @@ function combineChunks(chunks: Uint8Array[], totalLength: number): Uint8Array {
313228
}
314229

315230
/**
316-
* Check if we have a complete frame based on LEN byte
317-
* Frame structure: [LEN][CLASS][IDENT][STATUS][DATA...][CRC-16]
318-
* Total expected = LEN + 2 (for the LEN byte itself and trailing bytes) + 2 (CRC)
231+
* Check if we have a complete frame.
319232
*/
320233
function isCompleteFrame(data: Uint8Array): boolean {
321234
if (data.length < 4) return false;
322235

323236
const len = data[0];
324237

325-
// Known frame sizes for ASK RDR-518:
326-
// LEN=0x04: no-card response, total 8 bytes (LEN + 4 data + 2 CRC + 1)
327-
// LEN=0x0b: card-found response, total 15 bytes
238+
// ACK responses
239+
if (data.length === 1 && data[0] === 0x01) return true;
240+
if (data[0] === 0x01 && data.length > 1) {
241+
return isCompleteFrame(data.slice(1));
242+
}
243+
244+
// Known frame sizes
328245
if (len === 0x04 && data.length >= 8) return true;
329246
if (len === 0x0b && data.length >= 15) return true;
330247

331-
// Generic check: LEN value + 4 bytes (for LEN byte + some header + CRC)
332-
// The LEN field typically indicates payload size after the header
248+
// Generic check
333249
if (data.length >= len + 4) return true;
334250

335251
return false;
336252
}
337253

338-
/**
339-
* Get serial port info
340-
*/
341254
export function getSerialPortInfo(port: SerialPort): string {
342255
const info = port.getInfo();
343256

@@ -347,26 +260,18 @@ export function getSerialPortInfo(port: SerialPort): string {
347260
if (info.usbVendorId === 0x072f) {
348261
return 'ACS NFC Reader';
349262
}
350-
351263
if (info.usbVendorId && info.usbProductId) {
352264
return `Serial Device (${info.usbVendorId.toString(16)}:${info.usbProductId.toString(16)})`;
353265
}
354-
355266
return 'Serial Port';
356267
}
357268

358-
/**
359-
* Convert byte array to hex string
360-
*/
361269
export function toHexSerial(data: Uint8Array): string {
362270
return Array.from(data)
363271
.map(b => b.toString(16).padStart(2, '0').toUpperCase())
364272
.join(' ');
365273
}
366274

367-
/**
368-
* Convert hex string to byte array
369-
*/
370275
export function fromHexSerial(hex: string): Uint8Array {
371276
const cleanHex = hex.replace(/\s+/g, '');
372277
const bytes = new Uint8Array(cleanHex.length / 2);

0 commit comments

Comments
 (0)