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
155export type SerialLogCallback = ( direction : 'TX' | 'RX' | 'INFO' , data : string ) => void ;
166
17- // Global log callback - can be set by the application
187let logCallback : SerialLogCallback | null = null ;
198
209export 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
3928export 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
4634export 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- */
5843export function isWebSerialSupported ( ) : boolean {
5944 return 'serial' in navigator ;
6045}
6146
62- /**
63- * Request a serial port from the user
64- */
6547export 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- */
9671export 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- */
133101export 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 */
166122export 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- */
305217function 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 */
320233function 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- */
341254export 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- */
361269export 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- */
370275export function fromHexSerial ( hex : string ) : Uint8Array {
371276 const cleanHex = hex . replace ( / \s + / g, '' ) ;
372277 const bytes = new Uint8Array ( cleanHex . length / 2 ) ;
0 commit comments