serial-core 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,217 +1,737 @@
1
1
  # serial-core
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/serial-core.svg)](https://www.npmjs.com/package/serial-core)
4
- [![License: GPL-3.0](https://img.shields.io/badge/License-GPL%203.0-blue.svg)](https://opensource.org/licenses/GPL-3.0)
4
+ [![License: GPL-3.0](https://img.shields.io/badge/License-GPL%203.0-blue.svg)](LICENSE.md)
5
+
6
+ Serial communication for Node.js with device discovery, a bounded FIFO, optional handshake verification and automatic reconnection. TypeScript declarations include API documentation for IDE completion and hover help.
7
+
8
+ The API is under development before v1.0. This README describes the implementation in this repository. See [Compatibility](#compatibility) for deliberate behavior changes.
9
+
10
+ ## Contents
11
+
12
+ - [Installation and runtime](#installation-and-runtime)
13
+ - [Quick start](#quick-start)
14
+ - [Exports](#exports)
15
+ - [SerialService reference](#serialservice-reference)
16
+ - [Configuration](#configuration)
17
+ - [Events and connection states](#events-and-connection-states)
18
+ - [Discovery and PortScanner](#discovery-and-portscanner)
19
+ - [Autodiscovery by vendorId or productId](#autodiscovery-by-vendorid-or-productid)
20
+ - [Commands, responses and deadlines](#commands-responses-and-deadlines)
21
+ - [Automatic recovery and shutdown](#automatic-recovery-and-shutdown)
22
+ - [Parsers](#parsers)
23
+ - [Handshake](#handshake)
24
+ - [Buffer utilities](#buffer-utilities)
25
+ - [Errors and troubleshooting](#errors-and-troubleshooting)
26
+ - [Extension hooks and internals](#extension-hooks-and-internals)
27
+ - [Documentation for IDEs and AI](#documentation-for-ides-and-ai)
28
+ - [Development and validation](#development-and-validation)
29
+ - [Compatibility](#compatibility)
30
+
31
+ ## Installation and runtime
5
32
 
6
- > Resilient serial communication service with automatic reconnection and queues.
33
+ ```bash
34
+ npm install serial-core serialport
35
+ # or
36
+ pnpm add serial-core serialport
37
+ ```
7
38
 
39
+ `serialport` is a peer dependency (`^13.0.0`). Use Node.js 20 or newer to satisfy that peer's Node requirement. This package's own `engines` field currently declares Node >=18; it does not override the stricter peer requirement. Native serial bindings and OS permissions must work on the target machine. This library uses Node streams and Buffers; browser Web Serial is outside its API.
40
+
41
+ ES modules:
42
+
43
+ ```typescript
44
+ import { SerialService, SerialStatus, PortScanner } from 'serial-core';
45
+ import type { SerialConfig, SendOptions, PortFilter } from 'serial-core';
46
+ ```
8
47
 
9
- `serial-core` is a robust TypeScript library designed to manage serial port communications in Node.js applications. It provides a resilient architecture with built-in features for automatic reconnection, message queuing, and device scanning.
48
+ CommonJS:
10
49
 
11
- > [!WARNING]
12
- > This library is currently in active development. The API is subject to change at any time before the first stable release (v1.0.0).
50
+ ```javascript
51
+ const { SerialService, SerialStatus, PortScanner } = require('serial-core');
52
+ ```
13
53
 
14
- ## Features
54
+ The package includes ESM (`dist/index.js`), CommonJS (`dist/index.cjs`) and the corresponding TypeScript declarations. Use the package root; internal source paths are not exported.
15
55
 
16
- - **Resilience:** Automatically handles connection drops and attempts to reconnect.
17
- - **Message Queuing:** Internal queue manager ensures data is sent sequentially and handles backpressure.
18
- - **Auto-Discovery:** Can scan for devices by Vendor ID (VID) and Product ID (PID) if a specific path is not provided.
19
- - **Handshake Support:** Built-in mechanism to verify device connection with a handshake command and response pattern.
20
- - **TypeScript Support:** Written in TypeScript with full type definitions included.
21
- - **Event-Driven:** Extends `EventEmitter` to provide real-time updates on connection status and incoming data.
56
+ ## Quick start
22
57
 
23
- ## Installation
58
+ ```typescript
59
+ import { SerialService, SerialStatus } from 'serial-core';
24
60
 
25
- ```bash
26
- npm install serial-core serialport
61
+ const serial = new SerialService({
62
+ path: '/dev/ttyUSB0', // On Windows, for example: 'COM3'
63
+ baudRate: 9600,
64
+ autoConnect: true,
65
+ reconnectInterval: 2000
66
+ });
67
+
68
+ // autoConnect starts in a microtask, allowing these listeners to be registered first.
69
+ serial.on('status', status => console.log('State:', status));
70
+ serial.on('error', error => console.error('Serial error:', error.message));
71
+ serial.on('data', (data, alias) => {
72
+ console.log('Received:', data.toString(), 'Context:', alias);
73
+ });
74
+ serial.on('connected', ({ path, baudRate }) => {
75
+ console.log('Ready:', path, baudRate);
76
+ // Handle asynchronous failures inside listeners.
77
+ void serial.send('HELLO\n', { alias: 'hello' }).catch(console.error);
78
+ });
79
+ serial.on('disconnected', reason => console.log('Disconnected:', reason));
80
+
81
+ // Elsewhere in your application:
82
+ if (serial.status === SerialStatus.CONNECTED) {
83
+ console.log('Ready to send');
84
+ }
85
+
86
+ process.once('SIGINT', () => {
87
+ void serial.disconnect().catch(console.error);
88
+ });
89
+ ```
90
+
91
+ For discovery instead of a fixed path, omit `path` and provide `vendorId`, `productId`, `serialNumber`, or a combination. Supplying a fixed path takes precedence over these selectors.
92
+
93
+ ## Exports
94
+
95
+ | Export | Kind | Purpose |
96
+ | ------------------------------------------------------------ | ------------------------------ | ---------------------------------------------------------------------- |
97
+ | `SerialService` | Class | Connection ownership, lifecycle, events, FIFO and recovery. |
98
+ | `PortScanner` | Static utility class | Enumerate and filter devices without opening them. |
99
+ | `SerialStatus` | Constant and string union type | `DISCONNECTED`, `SCANNING`, `CONNECTING`, `CONNECTED`, `RECONNECTING`. |
100
+ | `toBuffer`, `toUint8Array`, `toString`, `toAscii`, `toArray` | Functions | Buffer conversion and decoding helpers. |
101
+ | `SerialConfig` | Type | Service configuration and nested handshake options. |
102
+ | `SendOptions` | Type | Per-command deadlines and response matching. |
103
+ | `PortFilter` | Type | Discovery selectors. |
104
+ | `SerialPortInfo` | Type | One metadata entry returned by `serialport.list()`. |
105
+ | `SerialEvents` | Type | Event names mapped to listener function signatures. |
106
+
107
+ `QueueManager`, `PortRegistry` and `QueueItem` are implementation details. They are documented below for maintainers but are not exported from the package root.
108
+
109
+ ## SerialService reference
110
+
111
+ ### Constructor
112
+
113
+ ```text
114
+ new SerialService(config: SerialConfig)
115
+ ```
116
+
117
+ Creates one service with its own command queue. Required: a positive finite `baudRate` and at least one non-empty selector among `path`, `vendorId`, `productId`, `serialNumber`. Invalid settings throw synchronously, including an invalid handshake regex when no custom callback is configured. `autoConnect` defaults to `false`.
118
+
119
+ ### All service methods and getters
120
+
121
+ | Member | Signature | Result and side effects |
122
+ | -------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
123
+ | Static `listPorts` | `SerialService.listPorts(filter?: PortFilter): Promise<SerialPortInfo[]>` | Fresh enumeration; optional exact filters. Does not open/reserve a port. Enumeration failures reject. |
124
+ | Static `findPorts` | `SerialService.findPorts(filter?: PortFilter): Promise<SerialPortInfo[]>` | Equivalent to static `listPorts(filter)`. |
125
+ | Instance `listPorts` | `serial.listPorts(filter?: PortFilter): Promise<SerialPortInfo[]>` | Delegates to the static method; does **not** automatically apply the service's configured selectors. |
126
+ | Instance `findPorts` | `serial.findPorts(filter?: PortFilter): Promise<SerialPortInfo[]>` | Same behavior as instance `listPorts`. Does not change service status. |
127
+ | `connect` | `serial.connect(): Promise<void>` | Starts an attempt. Resolves after teardown/discovery and initiation of opening, **not after readiness**. Operational failures use events/retry policy. |
128
+ | `disconnect` | `serial.disconnect(): Promise<void>` | Stops retries, invalidates pending attempts, rejects unfinished commands and waits for physical closure. Can reject if close fails. |
129
+ | `send` | `serial.send(data: string \| Buffer, options?: SendOptions): Promise<void>` | Requires `CONNECTED`; enqueues FIFO transmission. Resolves after drain and optional response; never returns response payloads. |
130
+ | `queueIsIdle` | `serial.queueIsIdle(): boolean` | True if there is no active or pending command. Does not imply connectivity or device idleness. |
131
+ | `portIsOpen` | `serial.portIsOpen(): boolean` | Current transport's `isOpen` flag, or false without a current transport. Can be true while verifying handshake. |
132
+ | `getPortPath` | `serial.getPortPath(): string \| null` | Current transport path, including opening/handshake. Null before transport creation or after cleanup; does not fall back to configured path. |
133
+ | `getLastAlias` | `serial.getLastAlias(): string \| null` | Active/latest processed command alias. May remain while idle; cleared on cleanup. |
134
+ | `autoConnect` | `serial.autoConnect: boolean` (read-only) | Constructor setting, not current connectivity. |
135
+ | `status` | `serial.status: SerialStatus` (read-only) | Lifecycle state; only `CONNECTED` permits sending. |
136
+
137
+ ```typescript
138
+ console.log({
139
+ configuredToStart: serial.autoConnect,
140
+ status: serial.status,
141
+ open: serial.portIsOpen(),
142
+ path: serial.getPortPath(),
143
+ idle: serial.queueIsIdle(),
144
+ lastAlias: serial.getLastAlias()
145
+ });
27
146
  ```
28
147
 
29
- *Note: `serialport` is a peer dependency.*
148
+ Calling `connect()` during `SCANNING`, `CONNECTING` or `CONNECTED` does nothing. Parallel calls do not create duplicate attempts, but their promises are not a shared readiness promise. Calling it explicitly while inactive/reconnecting cancels the scheduled retry and starts a new retry budget.
30
149
 
31
- ## Usage
150
+ ### Awaiting readiness explicitly
32
151
 
33
- Here is a basic example of how to use `SerialService`:
152
+ Register listeners before starting. Node's `once()` helper can await one successful connection with an application deadline:
34
153
 
35
154
  ```typescript
36
- import { SerialService, SerialStatus } from 'serial-core';
155
+ import { once } from 'node:events';
156
+ import { SerialService } from 'serial-core';
37
157
 
38
- // Configuration
39
- const config = {
40
- // Option 1: Direct path
41
- path: '/dev/ttyUSB0',
42
-
43
- // Option 2: Auto-discovery (used if path is not set)
44
- // vendorId: '1234',
45
- // productId: '5678',
158
+ const serial = new SerialService({ path: '/dev/ttyUSB0', baudRate: 9600 });
159
+ serial.on('error', error => console.error(error.message));
160
+
161
+ try {
162
+ await Promise.all([once(serial, 'connected', { signal: AbortSignal.timeout(10000) }), serial.connect()]);
163
+ await serial.send('PING\n');
164
+ } finally {
165
+ await serial.disconnect();
166
+ }
167
+ ```
168
+
169
+ `once(serial, 'connected')` rejects on the first `error` event or abort, so this recipe fails fast even if the service schedules another retry. Applications that want to remain available through failures should keep persistent `connected`, `reconnecting` and `reconnectFailed` listeners instead. Aborting this application wait does not itself stop the service; the `finally` block does.
170
+
171
+ ### Inherited EventEmitter methods
172
+
173
+ `SerialService` extends Node's typed `EventEmitter`. The methods below retain Node behavior; listener registration/removal does not open or close the serial port.
174
+
175
+ | Method | Behavior |
176
+ | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
177
+ | `on(event, listener)` / `addListener(event, listener)` | Append a listener; return the service for chaining. |
178
+ | `once(event, listener)` | Append a listener removed before its first invocation; returns the service. This is distinct from the promise helper imported from `node:events`. |
179
+ | `prependListener(event, listener)` | Place a persistent listener at the beginning. |
180
+ | `prependOnceListener(event, listener)` | Place a one-time listener at the beginning. |
181
+ | `off(event, listener)` / `removeListener(event, listener)` | Remove one matching registration; returns the service. Keep the original function reference. |
182
+ | `removeAllListeners(event?)` | Remove listeners for one/all events. Does not disconnect. Avoid removing listeners while handshake is running because verification uses an internal `data` listener. |
183
+ | `emit(event, ...args)` | Synchronously invoke listeners; returns whether listeners existed. Does not perform the underlying serial action. Manually emitting `connected` does not make sending possible. |
184
+ | `eventNames()` | Return event names with registered listeners. |
185
+ | `listenerCount(event, listener?)` | Count registrations, optionally for a specific function. |
186
+ | `listeners(event)` | Return a copy of listener functions, exposing original functions for once listeners. |
187
+ | `rawListeners(event)` | Return registered functions including once wrappers. |
188
+ | `getMaxListeners()` | Read the listener warning threshold. |
189
+ | `setMaxListeners(count)` | Set that threshold and return the service; 0 disables warnings. Does not limit serial commands. |
190
+
191
+ Node's `newListener` and `removeListener` meta-events also remain available. Standard Node static utilities are outside the serial-specific API and are not reimplemented here; their installed Node types define their signatures. Listener callbacks run synchronously. An async listener's rejected promise must be handled by that listener; the service does not enable `captureRejections`.
192
+
193
+ ## Configuration
194
+
195
+ All optional defaults apply when the field is omitted. A service snapshots the top-level config; parser objects, handshake objects and callback functions are referenced. Treat configuration and these objects as immutable during use.
196
+
197
+ | Property | Type | Default | Contract |
198
+ | ------------------------ | ------------------------------ | ------------------------------------ | -------------------------------------------------------------------------------- |
199
+ | `baudRate` | `number` | Required | Positive finite baud rate accepted by your device/driver. |
200
+ | `path` | `string` | Unset | Fixed path; takes precedence over discovery. |
201
+ | `vendorId` | `string` | Unset | Normalized exact USB VID, e.g. `'0403'`. |
202
+ | `productId` | `string` | Unset | Normalized exact USB PID; can be the only discovery ID. |
203
+ | `serialNumber` | `string` | Unset | Exact serial number; useful for identical devices. |
204
+ | `autoConnect` | `boolean` | `false` | Initiate in a microtask after construction. |
205
+ | `autoReconnect` | `boolean` | `true` | Recover from connection failure/unexpected disconnect. |
206
+ | `reconnectInterval` | `number` | `2000` | Initial retry delay, ms. |
207
+ | `reconnectBackoffFactor` | `number` | `1` | Finite multiplier >=1; 1 means fixed delay. |
208
+ | `maxReconnectInterval` | `number` | `Math.max(30000, reconnectInterval)` | Delay ceiling, at least `reconnectInterval`. |
209
+ | `maxReconnectAttempts` | `number` | `Infinity` | Non-negative integer retry budget, excluding the initial attempt, or `Infinity`. |
210
+ | `writeTimeout` | `number` | `5000` | Deadline for a physical write plus drain, ms; includes handshake writes. |
211
+ | `responseTimeout` | `number` | `5000` | Default response wait after writing, ms. |
212
+ | `maxQueueSize` | `number` | `1000` | Positive integer limit of active plus queued commands. |
213
+ | `parser` | `NodeJS.ReadWriteStream` | Unset | Reused parser; mutually exclusive with `parserFactory`. |
214
+ | `parserFactory` | `() => NodeJS.ReadWriteStream` | Unset | New parser per connection; recommended for stateful framing. |
215
+ | `handshake` | `SerialConfig['handshake']` | Unset | Verification settings described below. |
216
+
217
+ Timer values must be positive finite numbers <=`2147483647` ms. `0`, negative values, `NaN` and `Infinity` are not valid timeouts. Only `maxReconnectAttempts` accepts `Infinity`. Driver-specific serial settings such as parity, stop bits, flow control or control-line manipulation are not public configuration options in this service.
218
+
219
+ ## Events and connection states
220
+
221
+ ### States
222
+
223
+ | State | Meaning |
224
+ | -------------- | ------------------------------------------------------------------------------------------------------------------------ |
225
+ | `DISCONNECTED` | Initial/manual stop, disabled recovery or exhausted retries. A manual transition occurs before physical close completes. |
226
+ | `SCANNING` | Waiting for previous teardown and/or enumerating a candidate; also used briefly with a fixed path. |
227
+ | `CONNECTING` | Path reserved; opening or performing handshake verification. |
228
+ | `CONNECTED` | Transport ready and optional handshake completed. |
229
+ | `RECONNECTING` | An automatic retry has been scheduled. |
230
+
231
+ Typical flow: `DISCONNECTED → SCANNING → CONNECTING → CONNECTED`. Failure/loss usually goes to `RECONNECTING → SCANNING`; disabled/exhausted recovery goes to `DISCONNECTED`. Use `await disconnect()` to await closure; observing `DISCONNECTED` alone is insufficient.
232
+
233
+ ### Event reference
234
+
235
+ `SerialEvents` maps names to **listener functions**, not arrays of arguments:
236
+
237
+ | Event | Listener signature | When and caveats |
238
+ | ----------------- | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
239
+ | `status` | `(status: SerialStatus) => void` | Only when the value changes. |
240
+ | `data` | `(data: Buffer \| string, alias?: string) => void` | Raw chunk or parser output, including handshake traffic. Can contain unsolicited data. |
241
+ | `error` | `(err: Error) => void` | Operational failures, emitted only when an error listener is attached. Missing listeners do not prevent recovery. |
242
+ | `connected` | `(info: { path: string; baudRate: number }) => void` | Each successful connection after optional handshake and its command transmission. |
243
+ | `disconnected` | `(reason: string) => void` | Manual shutdown or unexpected open-session loss. Current reasons: `'Manual'`, `'Unexpected'`. Not a notification for every unsuccessful scan/open. |
244
+ | `reconnecting` | `(info: { attempt: number; delay: number }) => void` | Retry scheduled; attempt starts at 1, delay is ms. Scheduling does not prove an open attempt happened. |
245
+ | `reconnectFailed` | `(info: { attempts: number }) => void` | Retry budget exhausted. Initial attempt excluded; not emitted when `autoReconnect` is false. |
246
+
247
+ ```typescript
248
+ import type { SerialEvents } from 'serial-core';
249
+
250
+ const logRetry: SerialEvents['reconnecting'] = ({ attempt, delay }) => {
251
+ console.log(`Retry ${attempt} in ${delay}ms`);
252
+ };
253
+ serial.on('reconnecting', logRetry);
254
+ // Later, remove precisely your own listener:
255
+ serial.off('reconnecting', logRetry);
256
+ ```
257
+
258
+ Listeners can observe a state transition before a related event (for example `status: CONNECTED` before `connected`). Prefer the event payload over assumptions about ordering across concurrent user actions. Do not throw from event listeners.
46
259
 
260
+ ## Discovery and PortScanner
261
+
262
+ ### All scanner methods
263
+
264
+ | Method | Signature | Result |
265
+ | ----------- | -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
266
+ | `listPorts` | `PortScanner.listPorts(filter?: PortFilter): Promise<SerialPortInfo[]>` | Every matching metadata entry, in driver enumeration order. |
267
+ | `findPorts` | `PortScanner.findPorts(filter?: PortFilter): Promise<SerialPortInfo[]>` | Equivalent to `listPorts(filter)`. |
268
+ | `findPort` | `PortScanner.findPort(vendorId?: string, productId?: string): Promise<string \| null>` | First matching path; requires at least one non-empty VID/PID. Does not exclude reserved ports. |
269
+
270
+ All methods enumerate afresh, do not open/reserve devices, and reject on enumeration errors. `[]`/`null` means no match, not an enumeration failure. The first result is not sorted or guaranteed stable. Use serial numbers when device identity matters.
271
+
272
+ ### PortFilter
273
+
274
+ | Field | Type | Matching |
275
+ | --------------- | --------- | ---------------------------------------------------------------------------------------- |
276
+ | `path` | `string` | Exact case-sensitive path. |
277
+ | `vendorId` | `string` | Trim whitespace, lowercase, remove leading `0x` and leading zeros; then compare exactly. |
278
+ | `productId` | `string` | Same normalization as VID. |
279
+ | `serialNumber` | `string` | Exact case-sensitive serial number. |
280
+ | `manufacturer` | `string` | Exact case-sensitive manufacturer name. |
281
+ | `availableOnly` | `boolean` | Default false; exclude paths reserved by this loaded library instance. |
282
+
283
+ Every supplied field must match. Missing port metadata cannot match a supplied selector. Empty filters enumerate all ports. `availableOnly` does not probe another application's OS lock or reserve the result; availability may change before connection. The registry does not coordinate separate processes/workers or multiple separately bundled copies.
284
+
285
+ ### Port metadata
286
+
287
+ `SerialPortInfo` is derived from the installed `serialport.list()` return type and passed through without modification. `path` identifies the OS port. Metadata may also include `manufacturer`, `serialNumber`, `pnpId`, `locationId`, `vendorId`, `productId`. Optional values depend on OS/driver/device; do not assume they exist.
288
+
289
+ ```typescript
290
+ import { PortScanner, SerialService } from 'serial-core';
291
+
292
+ const all = await SerialService.listPorts();
293
+ const matching = await SerialService.findPorts({
294
+ vendorId: '0x0403',
295
+ productId: '6001',
296
+ availableOnly: true
297
+ });
298
+ const exactUnit = await PortScanner.findPorts({ serialNumber: 'DEVICE-001' });
299
+ const firstPath = await PortScanner.findPort('0403', '6001');
300
+ const productOnly = await PortScanner.findPort(undefined, '6001');
301
+
302
+ for (const port of all) {
303
+ console.log(port.path, port.serialNumber ?? 'No serial number');
304
+ }
305
+ ```
306
+
307
+ When discovery is configured in `SerialService`, it selects the first matching **unreserved** candidate on each attempt. It can recover after a USB path change. It does not try every candidate within one attempt to identify a device; use precise selectors and handshake verification.
308
+
309
+ ## Autodiscovery by vendorId or productId
310
+
311
+ Autodiscovery lets `SerialService` find and open a device without hardcoding its current `COM` or `/dev/tty*` path. **Omit `path`** and provide `vendorId`, `productId`, or both. These selectors belong in the service configuration, not just in `PortScanner` calls. With `autoConnect: true`, no manual `connect()` call is needed.
312
+
313
+ ### Discover by vendorId only
314
+
315
+ Matches ports from a USB vendor, regardless of product ID. The service selects the first enumerated matching port that is not already reserved by this library instance.
316
+
317
+ ```typescript
318
+ import { SerialService } from 'serial-core';
319
+
320
+ const serial = new SerialService({
47
321
  baudRate: 9600,
322
+ vendorId: '0403', // Hexadecimal USB vendor ID; no path required.
48
323
  autoConnect: true,
49
- reconnectInterval: 5000,
50
-
51
- // Optional: Verify connection
52
- // handshake: {
53
- // command: 'PING\n',
54
- // pattern: 'PONG',
55
- // timeout: 2000
56
- // }
57
- };
324
+ autoReconnect: true,
325
+ reconnectInterval: 2000
326
+ });
327
+
328
+ serial.on('error', error => console.error('Discovery/connection failed:', error.message));
329
+ serial.on('connected', ({ path }) => console.log('Discovered and ready:', path));
330
+ serial.on('data', data => console.log('Received:', data.toString()));
331
+
332
+ // During shutdown: await serial.disconnect();
333
+ ```
334
+
335
+ Vendor-only matching can select different products from that vendor. Add a product ID or serial number when your application requires a particular device.
58
336
 
59
- // Initialize Service
60
- const serial = new SerialService(config);
337
+ ### Discover by productId only
61
338
 
62
- // Listen to Status Changes
63
- serial.on('status', (status: SerialStatus) => {
64
- console.log(`Serial Status: ${status}`);
65
- // Possible values: DISCONNECTED, SCANNING, CONNECTING, CONNECTED, RECONNECTING
339
+ A product ID can be used without a vendor ID. Matches are not restricted to a particular vendor, so combine both IDs if different vendors expose the same product ID.
340
+
341
+ ```typescript
342
+ import { SerialService } from 'serial-core';
343
+
344
+ const serial = new SerialService({
345
+ baudRate: 115200,
346
+ productId: '6001', // Hexadecimal USB product ID; vendorId may be omitted.
347
+ autoConnect: true,
348
+ autoReconnect: true,
349
+ reconnectInterval: 2000
66
350
  });
67
351
 
68
- // Listen to Incoming Data
69
- serial.on('data', (data: any) => {
70
- console.log('Received:', data.toString());
352
+ serial.on('error', error => console.error('Discovery/connection failed:', error.message));
353
+ serial.on('connected', ({ path }) => console.log('Product discovered and ready:', path));
354
+ serial.on('reconnecting', ({ attempt, delay }) => console.log('Retry:', attempt, delay));
355
+
356
+ // During shutdown: await serial.disconnect();
357
+ ```
358
+
359
+ ### Discover by vendorId and productId together
360
+
361
+ Both IDs must match the same port. This narrows discovery to the desired USB vendor/product combination; optional `serialNumber` further identifies a specific unit.
362
+
363
+ ```typescript
364
+ import { SerialService } from 'serial-core';
365
+
366
+ const serial = new SerialService({
367
+ baudRate: 115200,
368
+ vendorId: '0403',
369
+ productId: '6001',
370
+ // serialNumber: 'DEVICE-001', // Set this to target one physical unit.
371
+ autoConnect: true,
372
+ autoReconnect: true,
373
+ reconnectInterval: 1000,
374
+ reconnectBackoffFactor: 2,
375
+ maxReconnectInterval: 10000
71
376
  });
72
377
 
73
- // Listen to Errors
74
- serial.on('error', (err: Error) => {
75
- console.error('Serial Error:', err.message);
378
+ serial.on('error', error => console.error(error.message));
379
+ serial.on('connected', ({ path, baudRate }) => {
380
+ console.log('Matching device ready:', path, baudRate);
381
+ // serial.send(...) is now allowed; use commands supported by your device.
76
382
  });
383
+ serial.on('disconnected', reason => console.log('Connection lost/stopped:', reason));
77
384
 
78
- // Send Data
79
- async function sendCommand() {
80
- try {
81
- // Sends data using the internal queue
82
- await serial.send('HELLO_WORLD\n');
83
- console.log('Message sent successfully');
84
- } catch (error) {
85
- console.error('Failed to send:', error);
86
- }
87
- }
385
+ // During shutdown: await serial.disconnect();
386
+ ```
88
387
 
89
- // Manual Control
90
- // serial.connect();
91
- // serial.disconnect();
388
+ IDs are hexadecimal **strings**, for example `'0403'` or `'0x0403'`, not decimal numbers. Matching normalizes whitespace, case, the `0x` prefix and leading zeros, then compares the complete ID. It does not use substring matching. Replace example IDs and baud rates with values from your device.
389
+
390
+ ### Find the IDs reported by your system
391
+
392
+ ```typescript
393
+ import { SerialService } from 'serial-core';
394
+
395
+ const ports = await SerialService.listPorts();
396
+ console.table(
397
+ ports.map(({ path, vendorId, productId, serialNumber }) => ({
398
+ path,
399
+ vendorId,
400
+ productId,
401
+ serialNumber
402
+ }))
403
+ );
92
404
  ```
93
405
 
94
- ## Handshake
406
+ If a port has no VID/PID metadata, those selectors cannot match it. Use a supported selector such as its exact serial number or configure a fixed path instead. Listing is read-only and can reject if OS/driver enumeration fails.
407
+
408
+ ### Discovery during startup and reconnection
409
+
410
+ 1. On startup, the service enumerates ports and chooses the first unreserved match. With `autoConnect: false`, start this explicitly with `await serial.connect()` and observe `connected` for readiness.
411
+ 2. If the device is absent, enumeration fails, opening fails or verification fails, the configured retry policy applies. With default unlimited retries, the service keeps looking until the device becomes available or the application calls `disconnect()`.
412
+ 3. After an unexpected disconnect, the service retires the old session and repeats the same discovery filters. It can find the device under a new path after USB replugging. Provide `serialNumber` if the same physical unit must be selected among identical VID/PID devices.
413
+ 4. `connected` fires for each successful connection. Pending commands from a lost session reject; they are not replayed. Send new or explicitly retry-safe commands only after readiness.
414
+ 5. `await serial.disconnect()` stops discovery retries and closes the session. `maxReconnectAttempts` or `autoReconnect: false` can stop automatic searching earlier.
415
+
416
+ A configured `path` always wins: setting `path`, `vendorId` and `productId` together uses the fixed path and does not perform discovery or validate that path's USB IDs. Omit `path` for the examples above. This feature is retry-driven discovery; it is not a separate continuously running port watcher.
417
+
418
+ ## Commands, responses and deadlines
419
+
420
+ ### SendOptions
421
+
422
+ | Field | Type | Default | Behavior |
423
+ | ----------------- | ------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------ |
424
+ | `alias` | `string` | Unset | Local context in `data` events and `getLastAlias()`. Never transmitted automatically. |
425
+ | `timeout` | `number` | See below | With `waitResponse`, response deadline starting after write/drain; otherwise queue write-wait deadline. |
426
+ | `waitResponse` | `boolean` | `false` | Require a response as well as successful write/drain. |
427
+ | `responseMatcher` | `(data: Buffer \| string) => boolean` | Unset | Used only with `waitResponse`. True accepts, false ignores, thrown error rejects the command. Synchronous callback only. |
428
+
429
+ ```typescript
430
+ await serial.send('SET LED 1\n'); // Await write/drain only.
95
431
 
96
- The handshake feature allows you to verify the device identity and connection before considering the port as fully connected. This is useful when you need to confirm that the connected device is the expected one.
432
+ await serial.send('READ\n', {
433
+ alias: 'temperature',
434
+ waitResponse: true,
435
+ timeout: 2000,
436
+ responseMatcher: frame => frame.toString().startsWith('TEMP:')
437
+ });
438
+ ```
439
+
440
+ The returned promise resolves to `void`; it does not return the response or append a newline. Observe `data` for payloads and include protocol terminators in the outgoing command yourself. Buffers are copied at enqueue time to prevent later caller mutation from changing a queued command.
441
+
442
+ Without a parser, `data` is arbitrary stream chunks; one response may span several chunks and one chunk may contain multiple messages. Without a matcher, the next received chunk/frame counts as a response, including unsolicited traffic. Configure a parser and protocol-aware matcher when correctness requires complete frames.
443
+
444
+ ### Deadline phases
445
+
446
+ | Phase | Deadline | Consequence |
447
+ | ------------------------------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
448
+ | Waiting behind another command | No independent enqueue deadline | Queue capacity is bounded; caller-level cancellation is not built in. |
449
+ | Physical write and drain | `config.writeTimeout`, always | Reject and apply connection recovery on failure/deadline. |
450
+ | Queue write wait with `waitResponse: false` | Optional `options.timeout`, starting when processing begins | Reject that command. Queue does not start another physical write until the writer settles. |
451
+ | Response after successful write/drain | `options.timeout ?? config.responseTimeout` | Reject that command and let the queue continue. Does not inherently reconnect the port. |
452
+ | Handshake | `handshake.timeout`, starting before sending its command | Failure invokes recovery; physical writeTimeout still also applies. |
453
+ | OS enumeration/open/close | No service deadline | Completion depends on OS/driver callbacks. Application wait deadlines do not forcibly cancel them. |
97
454
 
98
- ### Basic Handshake Configuration
455
+ A response received before write/drain completion is remembered, but cannot make the command succeed before transmission completes. A shorter queue-level write timeout can reject before the service-level physical timeout; the transport can remain busy during that interval.
456
+
457
+ ### Aliases and late replies
458
+
459
+ The service captures the response's alias before advancing the queue. However, an alias is local context for the active/latest command, not proof that the device is replying to it. It may persist while the queue is idle and appear on unsolicited input. After a response timeout, a late response can be mistaken for the next command's reply unless your protocol/matcher distinguishes request IDs or message types.
460
+
461
+ On any disconnect or connection failure, active and pending commands reject and **are not replayed**. A rejected command may already have reached the device. Decide at the application level which actions are safe to resend after `connected`.
462
+
463
+ ## Automatic recovery and shutdown
99
464
 
100
465
  ```typescript
101
466
  const serial = new SerialService({
102
- path: '/dev/ttyUSB0',
103
- baudRate: 9600,
467
+ baudRate: 115200,
468
+ vendorId: '0403',
469
+ productId: '6001',
470
+ serialNumber: 'DEVICE-001',
104
471
  autoConnect: true,
105
- reconnectInterval: 5000,
106
- handshake: {
107
- command: 'AT\r\n', // Command to send to the device
108
- pattern: /OK/, // Expected response pattern (string or RegExp)
109
- timeout: 2000 // Maximum time to wait for response (ms)
110
- }
472
+ autoReconnect: true,
473
+ reconnectInterval: 500,
474
+ reconnectBackoffFactor: 2,
475
+ maxReconnectInterval: 10000,
476
+ maxReconnectAttempts: 20
111
477
  });
478
+
479
+ serial.on('reconnecting', ({ attempt, delay }) => console.log({ attempt, delay }));
480
+ serial.on('reconnectFailed', ({ attempts }) => console.error('Retries exhausted:', attempts));
481
+ serial.on('error', error => console.error(error.message));
482
+ ```
483
+
484
+ For one-based retry `attempt`, the delay is:
485
+
486
+ ```text
487
+ delay = min(maxReconnectInterval, reconnectInterval * reconnectBackoffFactor ** (attempt - 1))
488
+ ```
489
+
490
+ Default factor 1 gives a fixed interval. Successful readiness resets the counter. `maxReconnectAttempts: 0` permits the initial attempt but no automatic retry. `autoReconnect: false` also disables retries. An explicit `connect()` after failure starts a new budget. There is at most one scheduled retry timer per service.
491
+
492
+ A fixed path is reused on every attempt. Discovery reruns selectors and excludes current library reservations. Errors without a subsequent `close` event also trigger cleanup/recovery; stale stream callbacks cannot replace the current connection's state. Internal reservations remain until closure succeeds.
493
+
494
+ ### Shutdown and reuse
495
+
496
+ ```typescript
497
+ await serial.disconnect();
498
+ // Later, reuse the same instance and its listeners:
499
+ await serial.connect(); // Still await/observe connected before sending.
112
500
  ```
113
501
 
114
- ### Handshake with Binary Data (Hex Comparison)
502
+ Manual disconnect cancels retries, invalidates in-flight discovery, cancels handshake listeners/timers and rejects unfinished commands. An in-flight open is closed when its callback arrives. `disconnect()` waits for that work; no service-level open/close timeout is imposed. If closing fails while the transport stays open, the promise rejects and its reservation is retained; call `disconnect()` again to retry.
115
503
 
116
- When working with devices that respond with binary data or non-printable characters, you can use the `hexPattern` option to compare responses in hexadecimal format:
504
+ User listeners remain attached for reuse. `disconnect()` is not a terminal dispose operation. There is no exported `destroy()`, `dispose()`, `reconnect()` or AbortSignal argument for service operations. Implement application-specific wait deadlines separately.
505
+
506
+ ## Parsers
117
507
 
118
508
  ```typescript
119
- const connectionCommand = Buffer.from([0x01, 0x02, 0x03, 0x04]);
509
+ import { ReadlineParser } from 'serialport';
510
+ import { SerialService } from 'serial-core';
511
+
512
+ const serial = new SerialService({
513
+ path: '/dev/ttyUSB0',
514
+ baudRate: 115200,
515
+ parserFactory: () => new ReadlineParser({ delimiter: '\n' })
516
+ });
517
+ serial.on('data', frame => console.log('Frame:', frame.toString()));
518
+ ```
120
519
 
520
+ Choose either `parser` or `parserFactory`. The factory is invoked once per transport creation and should return a fresh Node readable/writable parser whose output fits `Buffer | string`. Parser errors invoke recovery. The service pipes with `end: false`, unpipes on cleanup, and removes only its own parser listeners; it does not destroy the parser or manage a custom parser's external resources.
521
+
522
+ A shared `parser` can retain incomplete frames or buffered output across reconnects. Its reset/flush lifecycle is the client's responsibility. A fresh factory avoids carrying parser state into a new session. Handshake and normal `data` listeners see parser output when a parser is configured.
523
+
524
+ ## Handshake
525
+
526
+ Configure `handshake` to verify a responsive/expected device before `CONNECTED`:
527
+
528
+ ```typescript
121
529
  const serial = new SerialService({
122
530
  path: '/dev/ttyUSB0',
123
531
  baudRate: 9600,
124
- autoConnect: true,
125
- reconnectInterval: 5000,
126
- handshake: {
127
- command: connectionCommand,
128
- pattern: connectionCommand.toString('hex'), // Compare in hex format
129
- timeout: 3000,
130
- hexPattern: true // Enable hex comparison
131
- }
532
+ handshake: { command: 'PING\n', pattern: /^PONG/m, timeout: 2000 }
132
533
  });
133
534
  ```
134
535
 
135
- ### Handshake Configuration Options
536
+ ### Every handshake option
136
537
 
137
- | Property | Type | Required | Description |
138
- |----------|------|----------|-------------|
139
- | `command` | `string \| Buffer` | Yes | Command to send to the device upon connection. |
140
- | `pattern` | `string \| RegExp` | Yes | Expected pattern in the device response. |
141
- | `timeout` | `number` | Yes | Maximum time (in ms) to wait for the handshake response. |
142
- | `hexPattern` | `boolean` | No | If `true`, received data will be converted to hex before pattern matching. Useful for binary protocols. |
538
+ | Field | Type | Default | Contract |
539
+ | --------------- | ----------------------------------------------- | -------- | --------------------------------------------------------------------------------------------- |
540
+ | `command` | `string \| Buffer` | Required | Written directly after opening, bypassing the command FIFO. |
541
+ | `pattern` | `string \| RegExp` | Required | Regex or regex source. Still a required field when using callback, although ignored then. |
542
+ | `timeout` | `number` | Required | Positive finite deadline <=2147483647 ms, including command transmission. |
543
+ | `hexPattern` | `boolean` | `false` | Test lowercase hexadecimal instead of UTF-8 decoded accumulated input. Ignored with callback. |
544
+ | `callback` | `((data: Buffer \| string) => boolean) \| null` | Unset | Synchronous per-chunk/frame validator; takes priority over pattern and hexPattern. |
545
+ | `maxBufferSize` | `number` | `65536` | Positive integer accumulated-byte ceiling for regex matching. Not applied to callback. |
143
546
 
144
- ### How Handshake Works
547
+ ### Text and fragmented input
145
548
 
146
- 1. The service opens the serial port
147
- 2. Sends the handshake `command` to the device
148
- 3. Waits for a response that matches the `pattern`
149
- 4. If the pattern matches within the `timeout` period, the connection is considered successful
150
- 5. If no match or timeout occurs, the connection fails and a reconnection attempt is scheduled
549
+ Pattern matching accumulates bytes across incoming chunks, then decodes UTF-8 (or hex), so split responses and split UTF-8 characters can match. String patterns are compiled with `new RegExp(pattern)`: use regex escaping when literal characters are intended. Regex state is reset before each test, including global/sticky patterns. The buffer ceiling prevents unlimited accumulation; overflow fails the handshake.
151
550
 
152
- ### Handshake Examples
551
+ ### Binary input
153
552
 
154
- **Text-based handshake:**
155
553
  ```typescript
156
- handshake: {
157
- command: 'PING\n',
158
- pattern: 'PONG',
159
- timeout: 1000
160
- }
554
+ import type { SerialConfig } from 'serial-core';
555
+
556
+ const config: SerialConfig = {
557
+ path: '/dev/ttyUSB0',
558
+ baudRate: 9600,
559
+ handshake: {
560
+ command: Buffer.from([0xff, 0x01]),
561
+ pattern: /^ff0100/,
562
+ hexPattern: true,
563
+ timeout: 1000,
564
+ maxBufferSize: 1024
565
+ }
566
+ };
161
567
  ```
162
568
 
163
- **RegExp pattern for version checking:**
569
+ ### Custom validation
570
+
164
571
  ```typescript
165
- handshake: {
166
- command: 'VERSION\r\n',
167
- pattern: /v\d+\.\d+\.\d+/, // Matches v1.2.3 format
168
- timeout: 2000
169
- }
572
+ import type { SerialConfig } from 'serial-core';
573
+
574
+ const config: SerialConfig = {
575
+ path: '/dev/ttyUSB0',
576
+ baudRate: 9600,
577
+ handshake: {
578
+ command: 'GET_INFO\n',
579
+ pattern: '', // Required by the type, ignored when callback is present.
580
+ timeout: 2000,
581
+ callback: frame => frame.toString().startsWith('DEVICE:v1')
582
+ }
583
+ };
170
584
  ```
171
585
 
172
- **Binary protocol with hex comparison:**
586
+ The callback receives individual raw chunks or parser frames, not the accumulated regex buffer. Use a parser for complete messages or maintain your own bounded state. Return true to accept, false to keep waiting. A thrown exception fails verification; asynchronous callbacks are unsupported.
587
+
588
+ Readiness requires both successful command transmission and an accepted response. Handshake data is still emitted through `data`; `send()` rejects until ready. Verification failure closes/cleans the session and follows the configured recovery policy. Success, failure and manual disconnect cancel temporary verification listeners/timers.
589
+
590
+ ## Buffer utilities
591
+
592
+ All helpers are exported at the package root and use Node Buffer conversion semantics.
593
+
594
+ | Function | Signature | Allocation and behavior |
595
+ | -------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
596
+ | `toBuffer` | `toBuffer(data: string \| number[] \| Uint8Array \| Buffer): Buffer` | Returns the same Buffer; a Uint8Array becomes a view of the same backing memory/offset/length. Strings are UTF-8 encoded; arrays use `Buffer.from()` conversion into a new buffer. |
597
+ | `toUint8Array` | `toUint8Array(buf: Buffer): Uint8Array` | Returns the same object typed as Uint8Array; no copy. |
598
+ | `toString` | `toString(buf: Buffer, encoding?: BufferEncoding): string` | Decodes with `Buffer.toString`; default UTF-8. Supports encodings such as hex/base64. Does not mutate input. |
599
+ | `toAscii` | `toAscii(buf: Buffer): string` | `Buffer.toString('ascii')`; high bits are masked according to Node's ASCII decoder. |
600
+ | `toArray` | `toArray(buf: Buffer): number[]` | Independent new number array with byte values 0–255; allocation proportional to input length. |
601
+
173
602
  ```typescript
174
- handshake: {
175
- command: Buffer.from([0xFF, 0x01, 0x00]),
176
- pattern: 'ff0100', // Expected response in hex
177
- timeout: 3000,
178
- hexPattern: true
179
- }
603
+ import { toBuffer, toUint8Array, toString, toAscii, toArray } from 'serial-core';
604
+
605
+ const command = toBuffer([0x4f, 0x4b]);
606
+ console.log(toString(command)); // 'OK'
607
+ console.log(toString(command, 'hex')); // '4f4b'
608
+ console.log(toAscii(command)); // 'OK'
609
+ console.log(toArray(command)); // [79, 75]
610
+ console.log(toUint8Array(command) === command); // true
611
+
612
+ const bytes = new Uint8Array([1, 2]);
613
+ const shared = toBuffer(bytes);
614
+ shared[0] = 9;
615
+ console.log(bytes[0]); // 9: shared storage.
616
+ const independent = Buffer.from(shared); // Explicit copy when needed.
180
617
  ```
181
618
 
182
- ## API Reference
619
+ These helpers do not add protocol framing, endianness conversion, checksums or range validation for array elements. They delegate encoding/conversion behavior to Buffer. Although `toBuffer` can share storage, `serial.send(buffer)` copies its payload into the queue.
183
620
 
184
- ### `SerialService`
621
+ ## Errors and troubleshooting
185
622
 
186
- The main class for managing the serial connection.
623
+ | Symptom | Interpretation / action |
624
+ | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
625
+ | Constructor throws | Check required baud rate/selectors, numeric ranges, parser exclusivity and handshake regex. |
626
+ | Scanner rejects | OS/driver enumeration failed. Handle rejection; do not treat it as “no device”. |
627
+ | `Device not found in scan` | No unreserved match; verify selectors and available metadata with `listPorts()`. |
628
+ | `Port … occupied by another internal instance` | Another service in this library instance owns the exact path. Disconnect its owner or use discovery to select another candidate. |
629
+ | `Port not connected` | Wait for `connected`; a resolved `connect()` or true `portIsOpen()` is insufficient. |
630
+ | `Serial queue capacity exceeded` | Too many outstanding commands. Await/back off producers or choose an appropriate maxQueueSize. |
631
+ | `Write timeout` | Writer/drain stalled. Physical deadline invokes recovery; optional queue write timeout may expire earlier. |
632
+ | `Response timeout` | No accepted response after write. Check framing, delimiters, matcher and protocol timing; queue proceeds without an automatic response-timeout reconnect. |
633
+ | Handshake timeout / buffer limit / validation error | Verify command, regex/hex format, parser framing and callback behavior. Recovery follows config. |
634
+ | Close fails | Reservation retained while still open. Retry `disconnect()` and inspect the OS/driver. |
635
+ | UI says disconnected but port is still closing | Status is updated before closure; await `disconnect()` for completion. |
636
+ | Unexpected alias on unsolicited data | Alias is local active/last context, not protocol identity. Match request IDs in the device protocol. |
637
+
638
+ Error message text is diagnostic, not a stable error-code API. Always handle returned send/disconnect/scanner promises even when also listening for `error`. Normal `send` validation/capacity/response failures reject the command; not every rejection emits a service `error`. Unexpected service failures use the configured retry policy. OS-wide locks, permissions, cable faults and driver hangs cannot be ruled out by library-level reservations or tests.
639
+
640
+ ## Extension hooks and internals
641
+
642
+ The package root is the supported client entry point. The following protected hooks and source-only internals are documented for maintainers/subclass authors; they are not alternatives to the public methods.
643
+
644
+ ### Protected SerialService hooks
645
+
646
+ | Hook | Signature | Contract |
647
+ | ------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
648
+ | `setStatus` | `setStatus(status: SerialStatus): void` | Change state and emit status if changed. Does not open/close transport by itself. |
649
+ | `openPort` | `openPort(path: string): void` | Consume an already reserved path; install owned listeners, open and initiate readiness/handshake. Overrides must preserve resource and late-callback safeguards. |
650
+ | `performWrite` | `performWrite(data: string \| Buffer): Promise<void>` | Bypass FIFO and await physical write/drain with deadline. Used by FIFO and handshake. Override only with equivalent serialization/failure behavior. |
651
+ | `handleConnectionFailure` | `handleConnectionFailure(reason: string): void` | Reject unfinished work, clean up, apply retry policy and report failure; emit unexpected disconnection for an open session. |
652
+ | `scheduleReconnect` | `scheduleReconnect(): void` | Enforce one timer, stop flag, capped delay and retry budget; emit retry scheduling/exhaustion events. |
653
+ | `cleanup` | `cleanup(error?: Error): void` | Cancel verification, reject queue, detach owned listeners and initiate asynchronous teardown. Does not itself await closure, set state, schedule recovery or remove client listeners. |
654
+ | `performHandshake` | `performHandshake(): void` | No-op without open transport/config; otherwise send/verify with bounded regex accumulation and cancelable listeners/timers. |
655
+
656
+ Protected state is `port`, `queue`, `_status`, `config`, `intentionalDisconnect`, `reconnectTimer`. Do not mutate it casually: it participates in reservation ownership, retry accounting and readiness. Prefer composition through public events. A subclass that overrides opening/writing/cleanup takes responsibility for preserving lifecycle guarantees; protected hooks are not stable transport-adapter interfaces.
657
+
658
+ Private helpers (`startConnection`, `markConnected`, `reportError`, `closeRetiringPort`, queue `process`/`finish`, scanner `normalizeId`) implement these contracts and have source comments. They are not callable client APIs and have no supported import path.
659
+
660
+ ### Internal QueueManager
661
+
662
+ Source: `src/core/QueueManager.ts`. Not exported or independently installable through the package API.
663
+
664
+ | Member | Signature | Behavior |
665
+ | ---------------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
666
+ | Constructor | `new QueueManager(writeHandler, maxSize = 1000, responseTimeout = 5000)` | Injects `(data: Buffer \| string) => Promise<void>` and limits. SerialService validates constructor settings; direct queue construction does not. |
667
+ | `add` | `add(data: Buffer \| string, options?: SendOptions): Promise<void>` | Copy/enqueue/start a command; reject on capacity, deadline, writer/matcher error or clear. |
668
+ | `currentAlias` | `string \| undefined` getter | Active/latest alias, retained while idle. |
669
+ | `clear` | `clear(error?: Error): void` | Reject unfinished work, clear queue timers/context. Default error: queue cleared due to disconnection. Does not itself cancel the injected physical writer. |
670
+ | `notifyResponse` | `notifyResponse(data?: Buffer \| string): void` | Offer a response to the active wait; data may be omitted only for unfiltered notification. Captures early responses. |
671
+ | `isIdle` | `isIdle(): boolean` | No active/pending items; a timed-out writer remains active until it settles. |
672
+
673
+ A stale writer callback cannot settle a replacement command. After `clear()`, the service also retires the old transport. Direct users of this internal class must not allow a previous physical writer to overlap a new session. The queue alone does not impose a default physical-write deadline; the injected service writer does.
674
+
675
+ Internal `QueueItem` fields: `data`, `alias?`, `timeout?`, `waitResponse?`, `responseMatcher?` mirror command input/options; `resolve: () => void` and `reject: (err: Error) => void` settle the command. It is not exported for client use.
676
+
677
+ ### Internal PortRegistry
678
+
679
+ Source: `src/core/PortRegistry.ts`. One singleton per loaded module instance; its constructor is private.
680
+
681
+ | Method | Signature | Behavior |
682
+ | ------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------- |
683
+ | `getInstance` | `PortRegistry.getInstance(): PortRegistry` | Get/create the singleton. |
684
+ | `register` | `register(path: string): boolean` | Reserve exact path if absent; false if already reserved. No normalization or OS operations. |
685
+ | `unregister` | `unregister(path: string): void` | Remove exact reservation; unknown paths are a no-op. Service ownership must be respected. |
686
+ | `isLocked` | `isLocked(path: string): boolean` | Query the local exact-path reservation set. |
687
+
688
+ This registry does not identify path aliases or coordinate separate applications, workers or copies of the module. The transport/OS may still reject opening a listed unreserved port.
689
+
690
+ ## Documentation for IDEs and AI
691
+
692
+ - **Human reference:** this README covers all root exports, service/scanner methods, getters, options, events, helpers and internal extension contracts.
693
+ - **IDE reference:** source JSDoc documents parameters, return values, defaults, errors, memory ownership and examples. The build carries these comments into `dist/index.d.ts` and `dist/index.d.cts`, so installed-package consumers get hover help.
694
+ - **AI consumer reference:** [llms.txt](llms.txt) provides a compact API/contract map with explicit supported imports and common integration mistakes. It is included in the npm package as a documentation file; the export map still permits only the package root.
695
+ - **Repository agents:** [AGENTS.md](AGENTS.md) explains where behavior lives, how to validate changes and which guarantees must stay consistent across code, tests and docs.
696
+
697
+ No separate generated API website is required. Source/declarations define signatures; the README explains behavior; update all relevant documentation whenever a contract changes. Do not infer features from the package name or description that are absent from its exports.
698
+
699
+ ## Development and validation
700
+
701
+ ```bash
702
+ pnpm install
703
+ pnpm typecheck
704
+ pnpm exec vitest run
705
+ pnpm lint
706
+ pnpm build
707
+ ```
187
708
 
188
- #### Configuration (`SerialConfig`)
709
+ | Command | Purpose |
710
+ | --------------------- | ---------------------------------------------------------------------------------------- |
711
+ | `pnpm dev` | Watch/rebuild with tsdown. |
712
+ | `pnpm build` | Format `src/`, generate ESM/CommonJS bundles and declarations, clean prior build output. |
713
+ | `pnpm typecheck` | Check source TypeScript without emitting. |
714
+ | `pnpm test` | Interactive Vitest workflow; use `pnpm exec vitest run` for a single run. |
715
+ | `pnpm lint` | ESLint checks. |
716
+ | `pnpm format` | Format `src/` with Prettier. |
717
+ | `pnpm clean` | Remove generated `dist/`. |
718
+ | `pnpm prepublishOnly` | Build via the package's publish hook. |
189
719
 
190
- | Property | Type | Description |
191
- |----------|------|-------------|
192
- | `path?` | `string` | The system path to the serial port (e.g., `/dev/ttyUSB0`). |
193
- | `vendorId?` | `string` | USB Vendor ID for auto-discovery (if `path` is omitted). |
194
- | `productId?` | `string` | USB Product ID for auto-discovery. |
195
- | `baudRate` | `number` | Serial baud rate (default: `9600`). |
196
- | `autoConnect` | `boolean` | Whether to connect automatically on instantiation. |
197
- | `reconnectInterval` | `number` | Time in ms to wait before retrying connection. |
198
- | `handshake?` | `object` | Optional handshake configuration to verify device identity. See [Handshake](#handshake) section for details. |
720
+ `test/device.manual.ts` is a hardware-oriented example: edit its path/baud rate/selectors for your device and use your own TypeScript runner. There is no configured `test:manual` package script. Automated lifecycle/queue tests mock transport behavior; USB unplug/replug, driver behavior, serial settings and real protocol timing require hardware validation. See [CONTRIBUTING.md](CONTRIBUTING.md) for contributor guidance.
199
721
 
200
- #### Methods
722
+ ## Compatibility
201
723
 
202
- - **`connect(): Promise<void>`**: Initiates the connection process (scanning -> connecting -> handshake -> connected).
203
- - **`disconnect(): Promise<void>`**: Manually closes the connection and stops reconnection attempts.
204
- - **`send(data: string | Buffer, options?): Promise<void>`**: Queues data to be sent to the device.
205
- - **`status`: `SerialStatus`**: Getter for the current connection state.
724
+ Existing service APIs, `PortScanner.findPort(vendorId, productId)` and buffer helpers remain exported. Deliberate changes from earlier behavior:
206
725
 
207
- #### Events
726
+ - VID/PID use normalized exact comparison rather than substring matching.
727
+ - Enumeration failures reject instead of logging and returning null.
728
+ - Disconnect/failure rejects active and queued commands rather than retaining unfinished commands for replay.
729
+ - Queue capacity and physical/response deadlines have finite defaults.
730
+ - Invalid configuration throws during construction; service events infer argument types.
731
+ - Missing service error listeners do not stop automatic recovery; applications should still subscribe for diagnostics.
208
732
 
209
- - **`status`**: Emitted when the connection status changes.
210
- - **`connected`**: Emitted when a connection is successfully established (and handshake passes).
211
- - **`disconnected`**: Emitted when the connection is lost or closed.
212
- - **`data`**: Emitted when data is received from the device.
213
- - **`error`**: Emitted when an error occurs.
733
+ There is no public API for opening a browser serial chooser, adding polling watchers, returning a response from `send()`, canceling an individual queued command, changing baud rate while connected or manipulating control signals. Build those separately only if your application needs them; do not assume inherited EventEmitter methods provide serial operations.
214
734
 
215
735
  ## License
216
736
 
217
- GPL-3.0-only
737
+ GPL-3.0-only. See [LICENSE.md](LICENSE.md).