diff --git a/AGENTS.md b/AGENTS.md index 3e06ee9..40414d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,23 +6,24 @@ before touching code. ## What this is `web-escpos-printer` wraps ESC/POS receipt printing, **entirely in the -browser, no Node at runtime**. Two transports share one `PrinterTransport` -interface: Web Bluetooth (direct) and QZ Tray (talks to the +browser, no Node at runtime**. Four transports share one `PrinterTransport` +interface: Web Bluetooth (direct), QZ Tray (talks to the [QZ Tray](https://qz.io) desktop app's local websocket, which talks to an -OS-registered printer — USB or otherwise). Ships in two forms from one -source via `webpack.config.js` (array of two configs): - -- **Standalone UMD** (`build/web-escpos-printer.js`) — fully self-contained, - bundles `@point-of-sale/*`, `canvas-dither`, `qrcode-generator`, `qz-tray` - inside. Drop into a ` ``` -Pin a version for production (`@x.y.z` after the package name, e.g. -`web-escpos-printer@1.1.0`) instead of the unpinned URL above — it always -resolves to the latest release, which can break you without warning. - -See [demo/index.html](demo/index.html) for a full working example, and +Pin a version (`@x.y.z`) for production — an unpinned URL always resolves +to latest, which can break you without warning. See +[demo/index.html](demo/index.html) for a full example, and `docker compose up` (see [docker-compose.yml](docker-compose.yml)) to run -it locally at `http://localhost:3000/`. +it at `http://localhost:3000/`. ## npm package usage @@ -81,25 +71,33 @@ async function onPrintClick() { } ``` -`require('web-escpos-printer')` (Vue 2, older webpack, plain Node -tooling) resolves to the same self-contained bundle as standalone above; -`import` gets the ESM build with `@point-of-sale/receipt-printer-encoder` -and `qz-tray` as externals instead. Full TypeScript declarations ship in -`build/types`. +`require('web-escpos-printer')` resolves to the same self-contained UMD +bundle as standalone above; `import` gets the ESM build with +`@point-of-sale/receipt-printer-encoder`/`qz-tray` as externals instead. +TypeScript declarations ship in `build/types`. ## API ```ts class WebEscposPrinter { - static isSupported(): boolean // Web Bluetooth support - static isQzSupported(): boolean // WebSocket support (not whether QZ Tray itself is running) + static isSupported(): boolean // Web Bluetooth support + static isQzSupported(): boolean // WebSocket support (not whether QZ Tray is running) + static isSerialSupported(): boolean // Web Serial support + static isUsbSupported(): boolean // WebUSB support constructor(config?: WebEscposPrinterConfigInput) onStatusChange(cb: (event: PrinterStatusEvent) => void): () => void - connect(options?: { transport?: 'bluetooth'; compat?: boolean } | { transport: 'qz'; printerName?: string }): Promise - listQzPrinters(query?: string): Promise // QZ Tray only — no native device picker, list then pick one + connect(options?: + | { transport?: 'bluetooth'; compat?: boolean; profile?: BluetoothPrinterProfile } + | { transport: 'qz'; printerName?: string } + | { transport: 'serial'; options?: SerialConnectOptions } + | { transport: 'usb'; profile?: UsbPrinterProfile } + ): Promise + listQzPrinters(query?: string): Promise + reconnectSerial(previous: SerialPortIdentity, options?: SerialConnectOptions): Promise + reconnectUsb(previous: UsbDeviceIdentity, profile?: UsbPrinterProfile): Promise disconnect(): Promise isConnected(): boolean getPrinterInfo(): PrinterInfo | null @@ -107,63 +105,44 @@ class WebEscposPrinter { printReceipt(job: PrintJob): Promise printRaw(bytes: Uint8Array | number[]): Promise - renderPreview(job: PrintJob): Promise // no printer/connection needed — real scannable barcodes/QR/PDF417 + renderPreview(job: PrintJob): Promise // no printer/connection needed static renderPreview(job: PrintJob, config?: WebEscposPrinterConfigInput): Promise } ``` -`PrintJob.content` is an ordered list of elements: `text`, `image`, -`barcode`, `qrcode`, `pdf417`, `newline`, `rule`. See -[src/types.ts](src/types.ts) for the full shape of each one. Errors arrive -as a rejected Promise with a `.code`: -`unsupported | user-gesture-required | connect-cancelled | connect-failed | not-connected | busy | print-failed`. +`PrintJob.content` elements: `text`, `image`, `barcode`, `qrcode`, +`pdf417`, `newline`, `rule` — see [src/types.ts](src/types.ts) for full +shapes. Errors reject with a `.code`: `unsupported | user-gesture-required +| connect-cancelled | connect-failed | not-connected | busy | print-failed`. ## Safe mode (compatibility fallback) -Some elements support `safeMode: true`: instead of sending the printer's -native ESC/POS command, the element is rendered using a safer fallback — -for printers whose firmware doesn't support the native one. A general -per-element pattern: `pdf417` (raster image), `qrcode` (raster image) and -`rule` (plain ASCII `-` line) have it today; other elements (e.g. `text`, -for printers with unreliable font/codepage support) may gain it later: +`safeMode: true` renders an element via a safer fallback instead of its +native ESC/POS command, for printers whose firmware mishandles the native +one: ```ts -{ type: 'pdf417', value: '...', safeMode: true } -{ type: 'qrcode', value: '...', safeMode: true } -{ type: 'rule', safeMode: true } +{ type: 'pdf417', value: '...', safeMode: true } // raster image +{ type: 'qrcode', value: '...', safeMode: true } // raster image +{ type: 'rule', safeMode: true } // plain ASCII '-' line ``` -Off by default — the native command/character is smaller (or, for `rule`, -just looks different — solid vs. dashed) and works fine on printers that -already support it. Confirmed cases: some clone Bluetooth printers -silently drop native PDF417 -([docs/notes/09-clone-printers-lack-native-pdf417.md](docs/notes/09-clone-printers-lack-native-pdf417.md)) -and mangle the native rule character into garbage -([docs/notes/10-clone-printers-mangle-rule-character.md](docs/notes/10-clone-printers-mangle-rule-character.md)) -— both while an Epson prints the same bytes fine. +Off by default. See +[docs/notes/09](docs/notes/09-clone-printers-lack-native-pdf417.md) / +[10](docs/notes/10-clone-printers-mangle-rule-character.md) for the +confirmed clone-printer cases behind this. ## Connecting -Default `connect()` restricts the Bluetooth device picker to recognized -printer profiles: - -```ts -await printer.connect() // must be called from a real user click/tap -``` - -If your printer doesn't show up, use compatibility mode — the picker -lists every nearby device instead, matching the profile *after* -connecting (reaches more hardware, noisier picker): - ```ts -await printer.connect({ compat: true }) +await printer.connect() // Bluetooth, restricted to known profiles — must be a real user click/tap +await printer.connect({ compat: true }) // printer not showing up? broader picker, matches after connecting ``` ### Manual Bluetooth profile -If your printer isn't in this library's built-in profile table at all -(or matches the wrong one), you don't need to fork/rebuild — pass your -own profile directly, skipping the built-in table entirely: +Printer not in the built-in table (or matching the wrong one)? Pass your +own, no fork/rebuild needed: ```ts import type { BluetoothPrinterProfile } from 'web-escpos-printer' @@ -172,77 +151,90 @@ const myProfile: BluetoothPrinterProfile = { filters: [{ services: ['000018f0-0000-1000-8000-00805f9b34fb'] }], service: '000018f0-0000-1000-8000-00805f9b34fb', characteristic: '00002af1-0000-1000-8000-00805f9b34fb', + language: 'esc-pos', // 'esc-pos' | 'star-prnt' | 'star-line' + codepageMapping: 'default', // forwarded as-is to ReceiptPrinterEncoder + // messageSize/sleepAfterCommand: optional BLE write pacing for printers that drop data +} + +await printer.connect({ profile: myProfile }) // combine with { compat: true } too +``` + +Find `service`/`characteristic` with a BLE scanner app (nRF Connect, +LightBlue) against the printer — most clones use a vendor-specific +service. + +### Web Serial (USB cable, recommended default) + +Reliable across Windows/Linux/macOS, no extra software. Chrome/Edge +desktop only. + +```ts +const info = await printer.connect({ transport: 'serial' }) // shows the native port picker +await printer.connect({ transport: 'serial', options: { baudRate: 19200 } }) // default: 9600 8N1, no flow control + +// skip the picker later — reconnect silently: +const info2 = await printer.reconnectSerial({ usbVendorId: 0x0483, usbProductId: 0x5740 }) +if (!info2) await printer.connect({ transport: 'serial' }) +``` + +A vendor's own "virtual COM port" tool (e.g. Epson's TM Virtual Port +Assignment Tool) may not appear in the picker at all — not a filter bug, +see [docs/notes/12](docs/notes/12-vendor-virtual-com-drivers-not-listed.md); use QZ Tray for those. + +### WebUSB (USB cable, works only when nothing else has claimed the device) + +Cleanest transport where it works, but not reliably available on any OS +without freeing the device from another driver first — see +[docs/notes/11](docs/notes/11-webusb-blocked-by-kernel-driver-claims.md). +**Prefer Web Serial by default.** + +```ts +const info = await printer.connect({ transport: 'usb' }) // picker restricted to a known vendor/product-id table + +// unlisted printer — same escape hatch as BluetoothPrinterProfile: +import type { UsbPrinterProfile } from 'web-escpos-printer' +const myProfile: UsbPrinterProfile = { + filters: [{ vendorId: 0x0483, productId: 0x5743 }], + configuration: 1, + interface: 0, language: 'esc-pos', codepageMapping: 'default', } - -await printer.connect({ profile: myProfile }) -// also works combined with compat mode: -await printer.connect({ profile: myProfile, compat: true }) +await printer.connect({ transport: 'usb', profile: myProfile }) ``` -Field meanings: - -- `filters` — Web Bluetooth device-picker filters (same shape as - `requestDevice({ filters })`), OR'd together. Restricts the picker in - default mode; ignored (picker shows everything) in `compat: true` mode. -- `service` / `characteristic` — the GATT service and characteristic - UUIDs to write ESC/POS bytes to. Find these with a BLE scanner app (e.g. - nRF Connect, LightBlue) against the target printer. If in doubt about - what you're looking at, cross-check against the - [Bluetooth GATT services specification](https://www.bluetooth.com/specifications/gatt/services) - — most clone printers use a vendor-specific (non-standard) service, but - a scanner may also show standard GATT services you should ignore. -- `language` — `'esc-pos' | 'star-prnt' | 'star-line'`. -- `codepageMapping` — forwarded as-is to `ReceiptPrinterEncoder`; use - `'default'` if unsure. -- `messageSize` / `sleepAfterCommand` — optional BLE write pacing (max - bytes per chunk, delay between chunks) for printers that drop data - under the default pacing. See - [docs/notes/03-not-every-characteristic-supports-write-with-response.md](docs/notes/03-not-every-characteristic-supports-write-with-response.md) - for background on why some printers need this at all. - -For USB or other OS-registered printers, connect through -[QZ Tray](https://qz.io) instead (install the desktop app, pair your -printer there first). QZ has no native device picker, so list printers -yourself and pick one: +`reconnectUsb({ serialNumber, vendorId, productId }, profile?)` mirrors +`reconnectSerial()` for silent reconnect. + +### QZ Tray (fallback, any OS-registered printer) ```ts const printerNames = await printer.listQzPrinters() // opens the QZ Tray session if needed const info = await printer.connect({ transport: 'qz', printerName: printerNames[0] }) -// omit printerName to fall back to QZ Tray's own default printer ``` -QZ Tray shows its own permission popup per connect/print unless you -configure its certificate/signature plumbing yourself (not done by this -library — see [QZ Tray's docs](https://qz.io/wiki/2.0-signing-messages)). -Windows-only caveat: -[docs/notes/08-qz-windows-raw-driver-routing.md](docs/notes/08-qz-windows-raw-driver-routing.md). +Requires the QZ Tray desktop app installed and the printer paired there. +Shows its own permission popup per connect/print unless you configure its +certificate/signature plumbing yourself. Windows caveat: +[docs/notes/08](docs/notes/08-qz-windows-raw-driver-routing.md). ## Config -Paper width, protocol and codepage can be set once at construction, or -per print job — a per-job value always overrides the constructor's: - ```ts const printer = new WebEscposPrinter({ - paperWidth: '80mm', // '58mm' | '80mm' | '112mm' — shorthand for `columns` AND the image/barcode width ceiling - language: 'star-prnt', // 'esc-pos' | 'star-prnt' | 'star-line', default 'esc-pos' - codepageMapping: 'xprinter', // for non-standard clone printers; forwarded as-is to ReceiptPrinterEncoder - printerModel: 'epson-tm-t88vi', // lets ReceiptPrinterEncoder auto-configure known-model defaults - feedBeforeCut: 4, // blank lines fed before the physical cut, default 4 + paperWidth: '80mm', // '58mm' | '80mm' | '112mm' — shorthand for columns + image/barcode width ceiling + language: 'star-prnt', // 'esc-pos' | 'star-prnt' | 'star-line', default 'esc-pos' + codepageMapping: 'xprinter', // for non-standard clone printers + printerModel: 'epson-tm-t88vi',// lets ReceiptPrinterEncoder auto-configure known-model defaults + feedBeforeCut: 4, // blank lines fed before the cut, default 4 }) -// or per job: -await printer.printReceipt({ paperWidth: '58mm', content: [...] }) +await printer.printReceipt({ paperWidth: '58mm', content: [...] }) // per-job overrides the constructor ``` -`language`/`codepageMapping` a Bluetooth profile reports on `PrinterInfo` -is informational only — not applied to `printReceipt()` automatically. See -[docs/notes/02-paperwidth-scales-columns-and-imagemaxwidth.md](docs/notes/02-paperwidth-scales-columns-and-imagemaxwidth.md) -and -[docs/notes/04-feedbeforecut-defaults-to-zero.md](docs/notes/04-feedbeforecut-defaults-to-zero.md) -for why `paperWidth` and `feedBeforeCut` matter. +See [docs/notes/02](docs/notes/02-paperwidth-scales-columns-and-imagemaxwidth.md) +/ [04](docs/notes/04-feedbeforecut-defaults-to-zero.md) for why +`paperWidth`/`feedBeforeCut` matter. ## Building from source @@ -251,14 +243,12 @@ npm install npm run build # UMD + ESM + .d.ts (what gets published to npm) npm run build:standalone # only build/web-escpos-printer.js npm run build:dev # same as build, in watch mode -npm test # node:test suite against the real encoder — see AGENTS.md's "Testing" section +npm test # Vitest suite against the real encoder — see AGENTS.md's "Testing" section ``` ## License -MIT - -Note: the standalone UMD bundle (`build/web-escpos-printer.js`) statically -includes [`qz-tray`](https://www.npmjs.com/package/qz-tray), licensed -**LGPL-2.1** (every other bundled dependency is MIT). If you redistribute -that bundle, check LGPL-2.1's compliance requirements. +MIT. The standalone UMD bundle statically includes +[`qz-tray`](https://www.npmjs.com/package/qz-tray), licensed +**LGPL-2.1** (everything else bundled is MIT) — check LGPL-2.1's +compliance requirements if you redistribute that bundle. diff --git a/demo/app.js b/demo/app.js index eafc7ea..1e91a34 100644 --- a/demo/app.js +++ b/demo/app.js @@ -4,8 +4,17 @@ const statusPill = document.getElementById('statusPill') const logEl = document.getElementById('log') const tabBluetooth = document.getElementById('tabBluetooth') const tabQz = document.getElementById('tabQz') +const tabSerial = document.getElementById('tabSerial') +const tabUsb = document.getElementById('tabUsb') const panelBluetooth = document.getElementById('panelBluetooth') const panelQz = document.getElementById('panelQz') +const panelSerial = document.getElementById('panelSerial') +const panelUsb = document.getElementById('panelUsb') +const serialBanner = document.getElementById('serialBanner') +const usbBanner = document.getElementById('usbBanner') +const connectSerialBtn = document.getElementById('connectSerialBtn') +const serialBaudRateInput = document.getElementById('serialBaudRateInput') +const connectUsbBtn = document.getElementById('connectUsbBtn') const connectBtn = document.getElementById('connectBtn') const connectCompatBtn = document.getElementById('connectCompatBtn') const connectManualProfileBtn = document.getElementById('connectManualProfileBtn') @@ -61,15 +70,22 @@ function setPill(el, state) { el.className = `${PILL_BASE} ${PILL_STATE[state] || PILL_STATE.idle}` } -function selectTab(tab) { - const bluetooth = tab === 'bluetooth' - tabBluetooth.setAttribute('aria-selected', String(bluetooth)) - tabQz.setAttribute('aria-selected', String(!bluetooth)) - panelBluetooth.hidden = !bluetooth - panelQz.hidden = bluetooth +const TABS = { + bluetooth: [tabBluetooth, panelBluetooth], + qz: [tabQz, panelQz], + serial: [tabSerial, panelSerial], + usb: [tabUsb, panelUsb], +} +function selectTab(name) { + for (const [key, [tabEl, panelEl]] of Object.entries(TABS)) { + tabEl.setAttribute('aria-selected', String(key === name)) + panelEl.hidden = key !== name + } } tabBluetooth.onclick = () => selectTab('bluetooth') tabQz.onclick = () => selectTab('qz') +tabSerial.onclick = () => selectTab('serial') +tabUsb.onclick = () => selectTab('usb') const supported = WebEscposPrinter.isSupported() banner.textContent = supported ? 'Web Bluetooth supported' : 'Web Bluetooth not supported — use Chrome or Edge' @@ -78,6 +94,16 @@ connectBtn.disabled = !supported connectCompatBtn.disabled = !supported connectManualProfileBtn.disabled = !supported +const serialSupported = WebEscposPrinter.isSerialSupported() +serialBanner.textContent = serialSupported ? 'Web Serial supported' : 'Web Serial not supported — use Chrome or Edge on desktop' +setPill(serialBanner, serialSupported ? 'ok' : 'fail') +connectSerialBtn.disabled = !serialSupported + +const usbSupported = WebEscposPrinter.isUsbSupported() +usbBanner.textContent = usbSupported ? 'WebUSB supported' : 'WebUSB not supported — use Chrome or Edge on desktop' +setPill(usbBanner, usbSupported ? 'ok' : 'fail') +connectUsbBtn.disabled = !usbSupported + const STATUS_PILL_STATE = { connected: 'ok', printing: 'busy', connecting: 'busy', error: 'fail' } const printer = new WebEscposPrinter() @@ -99,6 +125,8 @@ printer.onStatusChange((event) => { // gated by `supported` — only by having already connected/listed. qzListBtn.disabled = connected qzConnectBtn.disabled = connected || qzPrinterSelect.value === '' + connectSerialBtn.disabled = connected || !serialSupported + connectUsbBtn.disabled = connected || !usbSupported }) connectBtn.onclick = async () => { @@ -181,6 +209,27 @@ qzConnectBtn.onclick = async () => { } } +connectSerialBtn.onclick = async () => { + const baudRate = serialBaudRateInput.value.trim() + const options = baudRate !== '' ? { baudRate: Number(baudRate) } : undefined + + try { + const info = await printer.connect({ transport: 'serial', options }) + log(`connected (Serial) to ${info.name}`) + } catch (error) { + log(`failed to connect (Serial): ${error.message}`) + } +} + +connectUsbBtn.onclick = async () => { + try { + const info = await printer.connect({ transport: 'usb' }) + log(`connected (USB) to ${info.name} (${info.language})`) + } catch (error) { + log(`failed to connect (USB): ${error.message}`) + } +} + disconnectBtn.onclick = async () => { await printer.disconnect() log('disconnected') diff --git a/demo/index.html b/demo/index.html index 877b3cf..e9bfe6e 100644 --- a/demo/index.html +++ b/demo/index.html @@ -27,6 +27,12 @@

Connect

+ +
@@ -104,6 +110,37 @@

Connect

+ + + +
idle diff --git a/docs/notes/02-paperwidth-scales-columns-and-imagemaxwidth.md b/docs/notes/02-paperwidth-scales-columns-and-imagemaxwidth.md index 0fa2606..b09f3d5 100644 --- a/docs/notes/02-paperwidth-scales-columns-and-imagemaxwidth.md +++ b/docs/notes/02-paperwidth-scales-columns-and-imagemaxwidth.md @@ -1,23 +1,14 @@ # Gotcha #2: `paperWidth` must scale both `columns` and `imageMaxWidth` -**`paperWidth` must scale both `columns` and `imageMaxWidth`** -(`config.ts#PAPER_WIDTH_SPECS`) — `imageMaxWidth` also caps image -resizing, the preview canvas width, and the barcode "too wide" check. -Scaling only `columns` (an earlier bug) left `paperWidth: '80mm'` doing -nothing for images/barcodes. +`config.ts#PAPER_WIDTH_SPECS` scales both — `imageMaxWidth` also caps +image resizing, preview canvas width, and the barcode "too wide" check. +`PaperWidth`: `'58mm'|'80mm'|'112mm'`. `80mm` verified against real +hardware (576 dots); `112mm` is an estimate. -`PaperWidth` is `'58mm' | '80mm' | '112mm'` — `80mm` cross-checked against -real hardware (576 dots); `112mm` is an estimate, not hardware-verified. - -**Known bug, found by the test suite below, not yet fixed**: `112mm` maps -to `columns: 56`, but the real encoder's constructor only accepts columns -of 32/35/42/44/48 (confirmed by reading the installed library) — it throws -`"The width of the paper must me either 32, 35, 42, 44 or 48 columns"`. -`paperWidth: '112mm'` therefore currently fails to build *any* receipt at -all, not just images. `58mm`(32)/`80mm`(42) are unaffected (both valid). -Pinned (as a currently-failing-on-purpose regression) by -`test/Printer/ReceiptBuilder.pdf417.test.ts`'s "paperWidth" suite — flip -that test to `doesNotReject` once this is actually fixed. +**Open bug**: `112mm` → `columns: 56`, but the real encoder only accepts +32/35/42/44/48 columns — throws, fails to build *any* receipt. `58mm`/ +`80mm` unaffected. Pinned (failing on purpose) by +`ReceiptBuilder.pdf417.test.ts`'s "paperWidth" suite. --- -Referenced from [AGENTS.md](../../AGENTS.md)'s "Critical gotchas" section (gotcha #2). +[AGENTS.md](../../AGENTS.md) gotcha #2. diff --git a/docs/notes/03-not-every-characteristic-supports-write-with-response.md b/docs/notes/03-not-every-characteristic-supports-write-with-response.md index 1941872..ac6b257 100644 --- a/docs/notes/03-not-every-characteristic-supports-write-with-response.md +++ b/docs/notes/03-not-every-characteristic-supports-write-with-response.md @@ -1,13 +1,10 @@ -# Gotcha #3: not every characteristic supports `writeValueWithResponse()` +# Gotcha #3: not every BLE characteristic supports `writeValueWithResponse()` -**Not every characteristic supports `writeValueWithResponse()`.** -Confirmed on real hardware (MTP-II clone): its print characteristic -only advertises `writeWithoutResponse`, so `writeValueWithResponse()` -throws `NotSupportedError` (legacy DOMException `.code === 9`) on the -first chunk. `writeChunked.ts`'s `pickWriter()` checks -`characteristic.properties` and picks whichever method is actually -supported (`write` preferred, `writeWithoutResponse` fallback) instead -of assuming. +Confirmed on an MTP-II clone: its print characteristic only advertises +`writeWithoutResponse`, so `writeValueWithResponse()` throws +`NotSupportedError` on the first chunk. `writeChunked.ts#pickWriter()` +checks `characteristic.properties` and picks whichever method is +actually supported instead of assuming. --- -Referenced from [AGENTS.md](../../AGENTS.md)'s "Critical gotchas" section (gotcha #3). +[AGENTS.md](../../AGENTS.md) gotcha #3. diff --git a/docs/notes/04-feedbeforecut-defaults-to-zero.md b/docs/notes/04-feedbeforecut-defaults-to-zero.md index bee5ef0..8872da7 100644 --- a/docs/notes/04-feedbeforecut-defaults-to-zero.md +++ b/docs/notes/04-feedbeforecut-defaults-to-zero.md @@ -1,21 +1,14 @@ # Gotcha #4: `cut()` defaults `feedBeforeCut` to `0` -**`cut()` defaults `feedBeforeCut` to `0`** unless a recognized -`printerModel` supplies its own `cutter.feed` (confirmed: -`feedBeforeCut = printerModel?.cutter?.feed || options.feedBeforeCut` — -model's value always wins). With zero feed, the physical cutter fires -before the last content clears it, slicing through it — confirmed on -real hardware: an Epson TM-T20X-II via QZ Tray cut text/barcodes/PDF417 -too close or clean through, worse for taller elements. `epson-tm-t20x` -isn't in the encoder's known-models table (`Unknown printer model`), so -no auto-fallback — its closest relatives (`epson-tm-t20iii`/`iv`) use -`cutter: { feed: 4 }`, also the single most common value across the -whole table (21/30 models). `DEFAULT_CONFIG.feedBeforeCut = 4` for this -reason — `ReceiptBuilder.ts` always passes it explicitly; -`PreviewRenderer.ts` mirrors the same gap before its "✂ cut" mark (see -AGENTS.md's "preview/print parity" rule in "Coding conventions"). +Zero feed lets the physical cutter slice through the last printed content +before it clears — confirmed on a real Epson TM-T20X-II (QZ Tray). +`epson-tm-t20x` isn't in the encoder's known-models table, so no +auto-fallback applies. `4` is the most common `cutter.feed` value across +the encoder's model table (21/30) — `DEFAULT_CONFIG.feedBeforeCut = 4` +for that reason; `PreviewRenderer.ts` mirrors the same gap before its cut +mark. -Pinned by `test/config.test.ts` (`DEFAULT_CONFIG.feedBeforeCut` must stay `4`). +Pinned by `test/config.test.ts`. --- -Referenced from [AGENTS.md](../../AGENTS.md)'s "Critical gotchas" section (gotcha #4). +[AGENTS.md](../../AGENTS.md) gotcha #4. diff --git a/docs/notes/05-bwip-js-validates-pdf417-capacity.md b/docs/notes/05-bwip-js-validates-pdf417-capacity.md index 8884fef..3d25ab8 100644 --- a/docs/notes/05-bwip-js-validates-pdf417-capacity.md +++ b/docs/notes/05-bwip-js-validates-pdf417-capacity.md @@ -1,19 +1,13 @@ # Gotcha #5: bwip-js validates PDF417 capacity; the real encoder does not -**bwip-js validates PDF417 capacity and throws when it doesn't fit; the -real encoder does not validate at all.** Confirmed: encoding 2000 chars -with `columns: 3` makes bwip-js reject with `pdf417insufficientCapacity`, -while the real encoder happily emits ESC/POS bytes requesting that same -impossible layout, leaving firmware behavior unverified. This is *why* -`resolvePdf417Columns()` (`Preview/content/pdf417.ts`, AGENTS.md gotcha #1) exists: the -shared capacity check both `ReceiptBuilder.ts` and `PreviewRenderer.ts` -run before committing to a non-auto `columns`, falling back to -fully-automatic (the pre-existing, safe behavior) when it doesn't fit. -Never pass a fixed `columns` to the real encoder without this check. +bwip-js throws (`pdf417insufficientCapacity`) when data doesn't fit a +fixed `columns`; the real encoder emits the bytes anyway, with unverified +firmware behavior. `resolvePdf417Columns()` (`Preview/content/pdf417.ts`) +runs this capacity check before `ReceiptBuilder`/`PreviewRenderer` commit +to a non-auto `columns`, falling back to automatic when it doesn't fit. +Never pass a fixed `columns` to the real encoder without it. -Pinned by `test/Preview/pdf417.raster.test.ts`'s `resolvePdf417Columns` -suite (auto mode must fall back to `undefined` instead of forwarding an -overflowing `columns`) and its own capacity-error propagation test. +Pinned by `test/Preview/pdf417.raster.test.ts`. --- -Referenced from [AGENTS.md](../../AGENTS.md)'s "Critical gotchas" section (gotcha #5). +[AGENTS.md](../../AGENTS.md) gotcha #5. diff --git a/docs/notes/06-bwip-js-default-eclevel-differs.md b/docs/notes/06-bwip-js-default-eclevel-differs.md index f2e66a8..db8c56c 100644 --- a/docs/notes/06-bwip-js-default-eclevel-differs.md +++ b/docs/notes/06-bwip-js-default-eclevel-differs.md @@ -1,18 +1,12 @@ -# Gotcha #6: bwip-js's default PDF417 `eclevel` differs from the real encoder's default `errorlevel` +# Gotcha #6: bwip-js's default PDF417 `eclevel` differs from the real encoder's default -**bwip-js's default PDF417 `eclevel` differs from the real encoder's -default `errorlevel`.** Confirmed by decoding actual ESC/POS bytes: the -real encoder, with `errorlevel` omitted, encodes level `1` (ASCII -`"01"`); bwip-js, with `eclevel` omitted, renders exactly like its own -`eclevel: 2`. Higher error-correction needs more codewords (more rows) -for identical data/columns — a second, subtler cause of preview-vs-print -shape mismatch that survived even after `columns` was aligned ([gotcha #5](05-bwip-js-validates-pdf417-capacity.md)). `Preview/content/pdf417.ts`'s `toBwipOptions()` now applies -`DEFAULT_ERRORLEVEL` (`= 1`) uniformly whenever the job doesn't set -`errorlevel`, so both `buildPdf417()` and `resolvePdf417Columns()` -render/validate against the same level the real print already assumes. +The real encoder defaults to `errorlevel: 1`; bwip-js's omitted `eclevel` +renders like `eclevel: 2` — different error-correction means different +row counts for identical data, a subtler preview/print shape mismatch +than gotcha #5. `Preview/content/pdf417.ts#toBwipOptions()` applies +`DEFAULT_ERRORLEVEL = 1` whenever the job doesn't set one. -Pinned by `test/Printer/ReceiptBuilder.pdf417.test.ts` (an explicit -`errorlevel: 1` must produce byte-identical output to leaving it unset). +Pinned by `ReceiptBuilder.pdf417.test.ts`. --- -Referenced from [AGENTS.md](../../AGENTS.md)'s "Critical gotchas" section (gotcha #6). +[AGENTS.md](../../AGENTS.md) gotcha #6. diff --git a/docs/notes/07-bwip-js-generic-api-pulls-in-full-engine.md b/docs/notes/07-bwip-js-generic-api-pulls-in-full-engine.md index 18c615d..3ce474f 100644 --- a/docs/notes/07-bwip-js-generic-api-pulls-in-full-engine.md +++ b/docs/notes/07-bwip-js-generic-api-pulls-in-full-engine.md @@ -1,32 +1,17 @@ -# Gotcha #7: `@bwip-js/browser`'s generic API pulls in its entire ~100-symbology engine +# Gotcha #7: `@bwip-js/browser`'s generic API pulls in the full ~100-symbology engine -**`@bwip-js/browser`'s generic `toCanvas()`/`toSVG()`/`render()` API -resolves the symbology via a runtime string (`bcid`), which pulls its -*entire* ~100-symbology engine into the bundle — always use the -per-symbology named exports instead.** The generic API calls -`bwipp_lookup(bcid)` internally, a dispatch table that references -every bundled symbology; since `bcid` is a runtime string (not a -static import), webpack can't prove which symbologies are actually -reachable and can't tree-shake the rest. Confirmed with a real -throwaway build: the generic pattern minified to 907 KiB; switching -`Preview/content/pdf417.ts`/`code128.ts`/`itf.ts` to the named exports -(`pdf417`/`code128`/`interleaved2of5`, each calling its own BWIPP -encoder directly, bypassing `bwipp_lookup` entirely) dropped that to -168 KiB combined — confirmed byte-identical behavior between the two -(same capacity-check results, same errors) since both paths go -through the same internal `_ToAny`/`_Render` machinery either way. -For the SVG capacity-check path (`resolvePdf417Columns()`, no -`` available), use `drawingSVG()` as the drawing argument — -`pdf417(opts, drawingSVG())` — instead of `toSVG({bcid: 'pdf417', ...})`. -Note `RenderOptions` still requires a `bcid` field on the options -object for typing purposes even when calling a named export directly — -it's structurally mandatory but never actually read on this path, so -keep it, just don't call `toCanvas`/`toSVG`/`render` with it. +`toCanvas()`/`toSVG()`/`render()` resolve the symbology via a runtime +string (`bcid`) through `bwipp_lookup()`, so webpack can't tree-shake +anything — confirmed: 907 KiB minified. Always use the per-symbology +named exports instead (`pdf417`/`code128`/`interleaved2of5`, each calling +its own encoder directly) — same behavior, 168 KiB combined. For the +capacity-check path with no ``, pass `drawingSVG()` as the +drawing arg instead of `toSVG()`. `RenderOptions.bcid` is still +structurally required for typing even when unused this way — keep it, +just don't call the generic API with it. -bwip-js was chosen for `code128`/`itf`/`pdf417` preview over hand-rolled -encoders for correctness, not just size: it auto-selects Code128 Subsets -A/B/C, where the hand-rolled version this project used to have only did -Subset B. +bwip-js was chosen over hand-rolled encoders for correctness too — it +auto-selects Code128 Subsets A/B/C. --- -Referenced from [AGENTS.md](../../AGENTS.md)'s "Critical gotchas" section (gotcha #7). +[AGENTS.md](../../AGENTS.md) gotcha #7. diff --git a/docs/notes/08-qz-windows-raw-driver-routing.md b/docs/notes/08-qz-windows-raw-driver-routing.md index 323dbf0..10ccb66 100644 --- a/docs/notes/08-qz-windows-raw-driver-routing.md +++ b/docs/notes/08-qz-windows-raw-driver-routing.md @@ -1,44 +1,18 @@ # Gotcha #8: on Windows, QZ Tray's raw print jobs go through the OS printer driver -**On Windows, QZ Tray's `type: 'raw'` print jobs go through the OS -printer driver, not byte-for-byte to the port — a driver can mangle -`GS k` (barcode) while leaving `GS ( k` (qrcode/pdf417) untouched, and -there's no code-level fix for it.** Reproduced on a real Epson -TM-T20X-II (USB, Windows driver): Code128/ITF printed garbled while -PDF417/QR were fine, on the same printer/transport — but all four -printed fine on a Bluetooth printer with the exact same bytes. Traced -to the byte level, not guessed: `buildReceiptBytes()` (AGENTS.md gotcha #1) is -transport-agnostic, so both transports get identical bytes; -`QzTransport.ts`'s `qz.print()` call hands its `Uint8Array` to -qz-tray's `compatible.data()` (`node_modules/qz-tray/qz-tray.js:889`), -which base64-encodes it via a binary-safe byte loop — no corruption in -this repo's code. The real encoder's `barcode()` emits `GS k` -(`0x1D 0x6B`) — ITF always NUL-terminated (legacy form), Code128 -length-prefixed but still `GS k` — while `qrcode()`/`pdf417()` emit -`GS ( k` (`0x1D 0x28 0x6B`) with an explicit 2-byte length prefix. -That split lines up exactly with which elements failed. qz-tray's own -JSDoc documents `options.forceRaw` (skips the driver, writes raw to the -port) as **"Not yet supported on Windows"** (defaults to `false`) — so -there is no way to bypass the driver for `type: 'raw'` jobs on Windows -through this API. **Fix**: not in this library — reconfigure the -printer in Windows. Two remedies confirmed on real hardware (this same -Epson TM-T20X-II): -- **Generic pass-through driver**: install a second Windows printer - object pointed at the same USB port, using the built-in "Generic / - Text Only" driver instead of the manufacturer/GDI one — untested end - to end on this hardware (port got reassigned/errored out mid-attempt) - but is the standard OS-level raw-passthrough fix and should work in - general. -- **Confirmed working**: reinstalling with Epson's own official driver - but picking a different printer *mode* offered during setup — the - user reported it listed as "**Dp 180**" (as seen in their installer; - exact official Epson terminology unverified from source, so take the - label loosely, not literally) — fixed Code128 **and** ITF, with - PDF417/QR still working, confirmed on both 58mm and 80mm paper. If - the manufacturer driver's setup offers multiple printer-model/mode - variants, trying a different one before resorting to Generic/Text - Only is worth it — costs nothing and keeps the OEM driver's other - features. +`type: 'raw'` QZ jobs are routed through the OS driver on Windows, not +byte-for-byte to the port — a driver can mangle `GS k` (barcode) while +leaving `GS ( k` (qrcode/pdf417) untouched, no code-level fix. Reproduced +on a real Epson TM-T20X-II: Code128/ITF garbled, PDF417/QR fine, same +bytes fine over Bluetooth. Traced to the byte level, not guessed — +`QzTransport.ts` hands qz-tray identical, uncorrupted bytes regardless of +transport; the split lines up exactly with `GS k` vs `GS ( k`. qz-tray's +own `forceRaw` option is documented "Not yet supported on Windows". + +**Fix**: OS-level, not this library. Confirmed remedies on the same +hardware: install a generic/"Text Only" pass-through driver, or (worked +end-to-end) pick a different mode in the manufacturer driver's own setup +— fixed Code128 *and* ITF with PDF417/QR still fine. --- -Referenced from [AGENTS.md](../../AGENTS.md)'s "Critical gotchas" section (gotcha #8). +[AGENTS.md](../../AGENTS.md) gotcha #8. diff --git a/docs/notes/09-clone-printers-lack-native-pdf417.md b/docs/notes/09-clone-printers-lack-native-pdf417.md index 82cf3f1..2ae1a00 100644 --- a/docs/notes/09-clone-printers-lack-native-pdf417.md +++ b/docs/notes/09-clone-printers-lack-native-pdf417.md @@ -1,30 +1,19 @@ # Gotcha #9: `safeMode` — raster-image fallback for unsupported commands -**`safeMode: true` on a PrintJobElement prints it as a raster image instead -of its native ESC/POS command, for printers that don't support that -command.** There's no code-level way to detect ahead of time whether a -given printer's firmware supports a given command — it doesn't report -that — so `safeMode` is an opt-in per-element escape hatch, not something -this library can decide automatically. `ReceiptBuilder.ts`'s -`safeMode()` helper (`Printer/Utils/safemode.ts`) is shared plumbing: -build a raster via the element's own builder, send it with -`encoder.image()` instead of the native command, or warn-and-skip if it -doesn't fit. Off by default — the native command produces a smaller -payload and is confirmed working on real hardware that does support it. +`safeMode: true` prints an element as a raster image instead of its +native ESC/POS command — firmware support can't be detected ahead of +time, so it's opt-in per element. `Printer/Utils/safemode.ts#safeMode()` +is the shared plumbing (build via the element's own builder, send via +`encoder.image()`, or warn-and-skip if it doesn't fit). Off by default — +native is smaller and works where supported. -Confirmed case, and the only one implemented so far: some cheap/clone -Bluetooth thermal printers don't implement the native PDF417 command -(`GS ( k`) at all, even though this library's own encoder emits correct -ESC/POS bytes for it — confirmed on a real Epson TM-T20X-II (prints it -correctly) vs. a clone Bluetooth printer (silently drops it, same bytes). -`pdf417` elements can set `safeMode: true` to work around it: -`buildPdf417RasterImage()` (`Preview/content/pdf417.ts`) reuses the same -bwip-js renderer `renderPreview()` already uses for the PDF417 preview. -Other element types may gain the same flag later. +Confirmed case: some clone Bluetooth printers silently drop the native +PDF417 command (`GS ( k`) entirely — a real Epson TM-T20X-II prints the +same bytes fine. `pdf417`'s `safeMode: true` reuses the PDF417 preview's +bwip-js renderer as the raster source. `qrcode` has the same flag, +proactively (no confirmed hardware case yet). -Pinned by `test/Printer/SafeMode.test.ts` (the shared substitution -mechanism) and `test/Printer/ReceiptBuilder.pdf417.test.ts`/`qrcode.test.ts` -(safeMode produces different bytes than the native command, end-to-end). +Pinned by `test/Printer/SafeMode.test.ts` + `ReceiptBuilder.pdf417/qrcode.test.ts`. --- -Referenced from [AGENTS.md](../../AGENTS.md)'s "Critical gotchas" section (gotcha #9). +[AGENTS.md](../../AGENTS.md) gotcha #9. diff --git a/docs/notes/10-clone-printers-mangle-rule-character.md b/docs/notes/10-clone-printers-mangle-rule-character.md index dda9114..15a10f2 100644 --- a/docs/notes/10-clone-printers-mangle-rule-character.md +++ b/docs/notes/10-clone-printers-mangle-rule-character.md @@ -1,33 +1,15 @@ # Gotcha #10: some clone printers mangle the native rule character -**`encoder.rule()` sends a cp437 box-drawing character — some clone -printers' font tables don't match it, printing garbage instead of a -line.** Confirmed by reading the bundled encoder -(`node_modules/@point-of-sale/receipt-printer-encoder/dist/*.esm.js`): +`encoder.rule()` repeats a cp437 box-drawing character (`─`/`═`) — +confirmed on the same clone Bluetooth printer as gotcha #9: prints as +`^^^^^^^^^` instead of a line (its font ROM doesn't map that byte the +same way). `rule` elements can set `safeMode: true` to send +`'-'.repeat(columns)` via `sendLine()` instead — plain ASCII is identical +across every codepage, so no raster image is needed (unlike gotcha #9). +Off by default — native looks different (solid vs. dashed) and works +where supported. -```js -rule(e){return e=Object.assign({style:"single",width:this.#c.columns||10},e||{}), - this.#h.flush(),this.#h.text(("double"===e.style?"═":"─").repeat(e.width),"cp437"), - this.#h.flush({forceNewline:!0}),this} -``` - -It repeats `─` (U+2500, or `═` for `style: 'double'`) to `columns` width, -encoded under the `cp437` codepage. On real hardware (the same clone -Bluetooth printer as gotcha #9's PDF417 case), this prints as -`^^^^^^^^^` instead of a line — the printer's actual font ROM doesn't map -that byte to the same glyph real cp437 does. - -`rule` elements can set `safeMode: true` to work around it: -`ReceiptBuilder.ts` sends `'-'.repeat(columns)` via `Text/sendLine.ts`'s -`sendLine()` instead of `encoder.rule()`. Plain ASCII `-` (0x20-0x7E) is -identical across every codepage, so this is safe on any printer — no -raster image needed, unlike `pdf417`'s safeMode (gotcha #9), since there's -a valid plain-text substitute here. Off by default: the native rule -character works fine on printers that support it, and produces a slightly -different (solid vs. dashed) look. - -Pinned by `test/Printer/ReceiptBuilder.rule.test.ts` (native `rule()` never -contains the plain-ASCII safeMode line, and vice versa). +Pinned by `test/Printer/ReceiptBuilder.rule.test.ts`. --- -Referenced from [AGENTS.md](../../AGENTS.md)'s "Critical gotchas" section (gotcha #10). +[AGENTS.md](../../AGENTS.md) gotcha #10. diff --git a/docs/notes/11-webusb-blocked-by-kernel-driver-claims.md b/docs/notes/11-webusb-blocked-by-kernel-driver-claims.md new file mode 100644 index 0000000..1e5de5d --- /dev/null +++ b/docs/notes/11-webusb-blocked-by-kernel-driver-claims.md @@ -0,0 +1,32 @@ +# Gotcha #11: WebUSB is blocked by a driver claim on Windows *and* Linux + +`device.open()`/`claimInterface()` fails with `SecurityError` ("Access +denied") the instant another driver already holds the USB interface — +confirmed on a real Epson TM-T20X-II happening on **both** Windows and +Linux, not Windows-only. Windows: an installed printer driver claimed it +(same category as gotcha #8). Linux: the kernel's `usblp` module +auto-binds any USB Printer-Class (0x07) device on plug-in, or a missing +udev permission rule. No code-level fix either way — WebUSB has no API to +force a driver to release its claim; `printerErrors.ts#normalizeOpenError()` +at least surfaces it as a clear `connect-failed` instead of the +misleading `user-gesture-required` it used to. + +**Confirmed Linux fix** (same hardware, vendor `04b8` = Epson): +```sh +lsusb # find idVendor:idProduct +echo 'SUBSYSTEM=="usb", ATTR{idVendor}=="04b8", ATTR{idProduct}=="0e27", MODE="0666", GROUP="plugdev"' \ + | sudo tee /etc/udev/rules.d/99-escpos.rules +sudo udevadm control --reload && sudo udevadm trigger +sudo modprobe -r usblp +echo "blacklist usblp" | sudo tee /etc/modprobe.d/blacklist-usblp.conf +ls -l /dev/bus/usb/003/007 # should read crw-rw-rw- +``` +**Windows fix**: same as gotcha #8 — a generic/pass-through driver, or a +different mode in the manufacturer driver's own setup. + +`printerErrors.ts` also `console.warn()`s a short pointer to this file on +a Linux `SecurityError`. **Recommendation**: default to `transport: +'serial'` — confirmed reliable where WebUSB wasn't. + +--- +[AGENTS.md](../../AGENTS.md) gotcha #11. diff --git a/docs/notes/12-vendor-virtual-com-drivers-not-listed.md b/docs/notes/12-vendor-virtual-com-drivers-not-listed.md new file mode 100644 index 0000000..9829bf6 --- /dev/null +++ b/docs/notes/12-vendor-virtual-com-drivers-not-listed.md @@ -0,0 +1,19 @@ +# Gotcha #12: a vendor's own "virtual COM port" driver isn't listed by Web Serial + +A printer bound to a Windows COM port via a vendor's proprietary tool +(confirmed: Epson's "TM Virtual Port Assignment Tool", `COM7` "EPSON COM +Emulation USB Port") never appears in Chrome's Web Serial picker, even +though Windows itself uses it fine — and it's not a filter in this +library (`SerialTransport.connect()` passes zero filters to +`requestPort()`, confirmed by reading the code). Bluetooth SPP virtual +ports *do* show up in the same picker. + +**Best-available explanation** (not source-verified): Web Serial only +recognizes USB CDC-ACM and Bluetooth SPP virtual ports — a vendor VCP +shim over a still-USB-Printer-Class (0x07) interface is neither. + +**No code-level fix** — use QZ Tray instead for printers whose only +"serial" option is a vendor VCP driver like this. + +--- +[AGENTS.md](../../AGENTS.md) gotcha #12. diff --git a/index.ts b/index.ts index b2b82e4..ace7d2b 100644 --- a/index.ts +++ b/index.ts @@ -13,6 +13,9 @@ export { WebEscposPrinter as default } from './src/Printer/WebEscposPrinter' export type { ConnectOptions } from './src/Printer/WebEscposPrinter' export type { BluetoothPrinterProfile } from './src/interfaces/bluetooth/profiles' +export type { UsbPrinterProfile } from './src/interfaces/usb/profiles' +export type { SerialConnectOptions, SerialPortIdentity } from './src/interfaces/serial/SerialTransport' +export type { UsbDeviceIdentity } from './src/interfaces/usb/UsbTransport' export type { WebEscposPrinterConfig, WebEscposPrinterConfigInput, diff --git a/package-lock.json b/package-lock.json index 5cc6218..02713e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,8 @@ "@types/jsdom": "^30.0.0", "@types/node": "^26.2.0", "@types/qz-tray": "^2.2.2", + "@types/w3c-web-serial": "^1.0.8", + "@types/w3c-web-usb": "^1.0.14", "@types/web-bluetooth": "^0.0.21", "canvas": "^3.2.3", "jsdom": "^27.0.1", @@ -29,7 +31,7 @@ "webpack-cli": "^5.1.4" }, "engines": { - "node": ">=22.6.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" } }, "node_modules/@asamuzakjp/css-color": { @@ -1183,6 +1185,20 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/w3c-web-serial": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/w3c-web-serial/-/w3c-web-serial-1.0.8.tgz", + "integrity": "sha512-QQOT+bxQJhRGXoZDZGLs3ksLud1dMNnMiSQtBA0w8KXvLpXX4oM4TZb6J0GgJ8UbCaHo5s9/4VQT8uXy9JER2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/w3c-web-usb": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/@types/w3c-web-usb/-/w3c-web-usb-1.0.14.tgz", + "integrity": "sha512-Qu3Nn6JFuF4+sHKYl+IcX9vYiI40ogleXzFFSxoE1W94rG98o/kXs8uJ0QSfFzuwBCZWlGfUGpPkgwuuX4PchA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/web-bluetooth": { "version": "0.0.21", "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", diff --git a/package.json b/package.json index d5ec20b..363a89f 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,8 @@ "@types/jsdom": "^30.0.0", "@types/node": "^26.2.0", "@types/qz-tray": "^2.2.2", + "@types/w3c-web-serial": "^1.0.8", + "@types/w3c-web-usb": "^1.0.14", "@types/web-bluetooth": "^0.0.21", "canvas": "^3.2.3", "jsdom": "^27.0.1", diff --git a/src/Printer/WebEscposPrinter.ts b/src/Printer/WebEscposPrinter.ts index 9fc5b10..58cf4e5 100644 --- a/src/Printer/WebEscposPrinter.ts +++ b/src/Printer/WebEscposPrinter.ts @@ -2,11 +2,14 @@ import { resolveConfig } from '../../config' import { DefaultBluetoothTransport, isBluetoothSupported } from '../interfaces/bluetooth/DefaultBluetoothTransport' import { CompatBluetoothTransport } from '../interfaces/bluetooth/CompatBluetoothTransport' import { QzTransport, isQzSupported } from '../interfaces/qz/QzTransport' +import { SerialTransport, isSerialSupported, type SerialConnectOptions, type SerialPortIdentity } from '../interfaces/serial/SerialTransport' +import { UsbTransport, isUsbSupported, type UsbDeviceIdentity } from '../interfaces/usb/UsbTransport' import { buildReceiptBytes } from './ReceiptBuilder' import { normalizePrintError } from '../interfaces/printerErrors' import { renderPreviewCanvas } from '../Preview/PreviewRenderer' import type { PrinterTransport } from '../interfaces/PrinterTransport' import type { BluetoothPrinterProfile } from '../interfaces/bluetooth/profiles' +import type { UsbPrinterProfile } from '../interfaces/usb/profiles' import type { PrinterError, PrinterInfo, @@ -49,6 +52,30 @@ export type ConnectOptions = */ printerName?: string } + | { + transport: 'serial' + /** + * Overrides for `SerialPort.open()` (baudRate/dataBits/stopBits/ + * parity/flowControl/bufferSize). Defaults to 9600 8N1, no flow + * control — see SerialTransport.ts. The device picker itself shows + * every COM port; Web Serial has no vendor/product-id-based printer + * profile to filter it by, unlike Bluetooth/WebUSB. + */ + options?: SerialConnectOptions + } + | { + transport: 'usb' + /** + * Skips profiles.ts's built-in USB profile table entirely and + * connects using this exact filters/configuration/interface/ + * language/codepageMapping — for printers this library doesn't + * recognize. See UsbPrinterProfile + * (src/interfaces/usb/profiles.ts) for the full field shape — same + * escape hatch as Bluetooth's manual profile, see the README's + * "Manual Bluetooth profile" section. + */ + profile?: UsbPrinterProfile + } /** * Public API of the wrapper. This is what gets exposed as `window.WebEscposPrinter` @@ -63,17 +90,20 @@ export class WebEscposPrinter { private readonly bluetooth = new DefaultBluetoothTransport() private readonly compatBluetooth = new CompatBluetoothTransport() private readonly qz: QzTransport + private readonly serial: SerialTransport + private readonly usb = new UsbTransport() private active: PrinterTransport | null = null private readonly listeners = new Set<(event: PrinterStatusEvent) => void>() private printing = false constructor(config?: WebEscposPrinterConfigInput) { this.config = resolveConfig(config) - // Must be assigned here in the constructor body, not as a class-field - // initializer above — field initializers run in declaration order + // Must be assigned here in the constructor body, not as class-field + // initializers above — field initializers run in declaration order // before any of the constructor's own statements, so `this.config` - // wouldn't be populated yet if this were one too. + // wouldn't be populated yet if these were too. this.qz = new QzTransport({ language: this.config.language, codepageMapping: this.config.codepageMapping }) + this.serial = new SerialTransport({ language: this.config.language, codepageMapping: this.config.codepageMapping }) } /** true if the current browser supports Web Bluetooth. Never throws. */ @@ -91,6 +121,16 @@ export class WebEscposPrinter { return isQzSupported() } + /** true if the current browser supports Web Serial. Never throws. */ + static isSerialSupported(): boolean { + return isSerialSupported() + } + + /** true if the current browser supports WebUSB. Never throws. */ + static isUsbSupported(): boolean { + return isUsbSupported() + } + /** Subscribes to status/error changes. Returns a function to cancel the subscription. */ onStatusChange(callback: (event: PrinterStatusEvent) => void): Unsubscribe { this.listeners.add(callback) @@ -116,6 +156,16 @@ export class WebEscposPrinter { * Pass `{ transport: 'qz', printerName }` to connect through the QZ Tray * desktop app instead — see `listQzPrinters()` to discover `printerName`, * since QZ has no native OS device picker the way Web Bluetooth does. + * + * Pass `{ transport: 'serial' }` for a USB-cable printer reachable over + * its virtual COM port — the reliable no-extra-software option on + * Windows (see SerialTransport.ts). Pass `{ transport: 'usb' }` to talk + * to its USB bulk endpoint directly instead — cleaner where it works + * (Linux/macOS/Android), but blocked on Windows the moment an OS printer + * driver has already claimed the device (see UsbTransport.ts). Both also + * require a user gesture, same as Bluetooth. `reconnectSerial()`/ + * `reconnectUsb()` re-open a previously granted port/device silently, no + * user gesture needed. */ async connect(options?: ConnectOptions): Promise { this.emit('connecting') @@ -132,8 +182,8 @@ export class WebEscposPrinter { } /** - * One case per transport — adding a new one later (e.g. a future WebUSB - * transport) means adding a case here, not touching an if/else chain. + * One case per transport — adding a new one means adding a case here, + * not touching an if/else chain. */ private async connectTransport(options?: ConnectOptions): Promise<{ transport: PrinterTransport; info: PrinterInfo }> { switch (options?.transport) { @@ -142,6 +192,16 @@ export class WebEscposPrinter { return { transport: this.qz, info } } + case 'serial': { + const info = await this.serial.connect(options.options) + return { transport: this.serial, info } + } + + case 'usb': { + const info = await this.usb.connect(options.profile) + return { transport: this.usb, info } + } + case 'bluetooth': case undefined: { const transport = options?.compat ? this.compatBluetooth : this.bluetooth @@ -163,6 +223,61 @@ export class WebEscposPrinter { return this.qz.listPrinters(query) } + /** + * Silently re-opens a serial port the user already granted access to in + * a previous session — no native picker prompt, so this is safe to call + * on page load without a user gesture. `previous` is matched against + * `navigator.serial.getPorts()` by vendor/product id — see + * SerialTransport.reconnect(). Resolves to `null`, not an error, when + * nothing matches (e.g. first visit, or the port was revoked); call + * `connect({ transport: 'serial' })` in that case instead, which does + * show the picker. + */ + async reconnectSerial(previous: SerialPortIdentity, options?: SerialConnectOptions): Promise { + this.emit('connecting') + try { + const info = await this.serial.reconnect(previous, options) + if (info) { + this.active = this.serial + this.emit('connected', info) + } else { + this.emit('idle') + } + return info + } catch (error) { + const printerError = error as PrinterError + this.emit('error', null, printerError) + throw printerError + } + } + + /** + * Silently re-opens a USB device the user already granted access to in a + * previous session — no native picker prompt, safe to call on page load. + * `previous` is matched against `navigator.usb.getDevices()` by + * serialNumber first, falling back to vendor/product id — see + * UsbTransport.reconnect(). Resolves to `null`, not an error, when + * nothing matches; call `connect({ transport: 'usb' })` in that case + * instead, which does show the picker. + */ + async reconnectUsb(previous: UsbDeviceIdentity, profile?: UsbPrinterProfile): Promise { + this.emit('connecting') + try { + const info = await this.usb.reconnect(previous, profile) + if (info) { + this.active = this.usb + this.emit('connected', info) + } else { + this.emit('idle') + } + return info + } catch (error) { + const printerError = error as PrinterError + this.emit('error', null, printerError) + throw printerError + } + } + async disconnect(): Promise { await this.active?.disconnect() this.emit('disconnected') diff --git a/src/interfaces/logger.ts b/src/interfaces/logger.ts new file mode 100644 index 0000000..8b008c8 --- /dev/null +++ b/src/interfaces/logger.ts @@ -0,0 +1,18 @@ +/** + * Single place diagnostic output is routed through, instead of scattering + * raw `console.*` calls across transports. + * + * Deliberately not a real logging library (e.g. pino): pino's own browser + * build has no file transports/workers/pretty-printing (those are + * Node-only) — in a browser it's effectively `console.*` with formatting + * on top, not worth a new runtime dependency in a package that's + * "entirely in the browser, no Node at runtime" and whose bundle already + * trips webpack's size-warning threshold (see AGENTS.md). If that + * calculus ever changes, swap this file's implementation — no call site + * elsewhere needs to change. + */ +export const logger = { + warn(message: string): void { + console.warn(message) + }, +} diff --git a/src/interfaces/printerErrors.ts b/src/interfaces/printerErrors.ts index 11b5266..cebc669 100644 --- a/src/interfaces/printerErrors.ts +++ b/src/interfaces/printerErrors.ts @@ -1,4 +1,5 @@ import type { PrinterError, PrinterErrorCode } from '../types' +import { logger } from './logger' const PRINTER_ERROR_CODES = new Set([ 'unsupported', @@ -10,6 +11,8 @@ const PRINTER_ERROR_CODES = new Set([ 'print-failed', ]) +// ---- primitives, shared by every normalizer below ---- + /** Shared by every printer transport so error codes stay consistent across strategies. */ export function toPrinterError(code: PrinterError['code'], message: string): PrinterError { return { code, message } @@ -38,22 +41,26 @@ export function isPrinterError(error: unknown): error is PrinterError { ) } +// ---- lifecycle-stage normalizers, in connection-flow order: pick a +// device/port -> open/claim it -> print to it ---- + /** - * Normalizes the native DOMExceptions thrown by requestDevice()/gatt.connect() - * into the wrapper's error codes. Used by both Bluetooth transports. + * Normalizes the native DOMExceptions thrown by requestDevice()/requestPort()/ + * gatt.connect() — the device/port *picker* call itself — into the + * wrapper's error codes. Used by every transport's connect(). */ export function normalizeConnectError(error: unknown): PrinterError { if (isPrinterError(error)) return error const name = (error as { name?: string })?.name - // requestDevice() throws SecurityError when connect() wasn't called from - // a user gesture (e.g. triggered automatically on load). + // requestDevice()/requestPort() throws SecurityError when connect() + // wasn't called from a user gesture (e.g. triggered automatically on load). if (name === 'SecurityError') { return toPrinterError('user-gesture-required', 'connect() must be called from a user click.') } - // User closed the Bluetooth device picker without choosing one. + // User closed the device picker without choosing one. if (name === 'NotFoundError') { return toPrinterError('connect-cancelled', 'Connection cancelled by the user.') } @@ -61,8 +68,45 @@ export function normalizeConnectError(error: unknown): PrinterError { return toPrinterError('connect-failed', errorMessage(error)) } +/** + * For failures *after* a device/port is already chosen — opening/claiming + * it (WebUSB's `open()`/`claimInterface()`, Web Serial's `port.open()`) — + * unlike normalizeConnectError() above (the picker call itself). Confirmed + * on real hardware: `SecurityError` here means another process already has + * exclusive access (e.g. a Windows printer driver holding the USB + * interface), not "missing user gesture" — mapped to `connect-failed` + * instead, with `hint` naming the real cause. + */ +export function normalizeOpenError(error: unknown, hint: string): PrinterError { + if (isPrinterError(error)) return error + + const name = (error as { name?: string })?.name + if (name !== 'SecurityError' && name !== 'NetworkError' && name !== 'InvalidStateError') { + return toPrinterError('connect-failed', errorMessage(error)) + } + + warnIfLinuxUsbAccessDenied(name) + return toPrinterError('connect-failed', `${errorMessage(error)} — ${hint}`) +} + /** Normalizes any error thrown while printing into a `print-failed` PrinterError. */ export function normalizePrintError(error: unknown): PrinterError { if (isPrinterError(error)) return error return toPrinterError('print-failed', errorMessage(error)) } + +/** + * Confirmed fix by a user of this library, on Linux specifically: the + * kernel's usblp driver auto-claims USB Printer-Class devices, or udev + * hasn't granted the browser access (gotcha #11) — a `console.warn()`-only + * devtools hint pointing at the full fix, never appended to the thrown + * `PrinterError.message` above (which stays short/UI-safe). + */ +function warnIfLinuxUsbAccessDenied(errorName: string): void { + if (errorName !== 'SecurityError') return + if (typeof navigator === 'undefined' || !/Linux/.test(navigator.userAgent) || /Android/.test(navigator.userAgent)) return + + logger.warn( + 'web-escpos-printer: "Access denied" on Linux usually means the kernel\'s usblp driver claimed the device, or udev hasn\'t granted access — see docs/notes/11-webusb-blocked-by-kernel-driver-claims.md (lsusb, a udev rule, `sudo modprobe -r usblp`).', + ) +} diff --git a/src/interfaces/serial/SerialTransport.ts b/src/interfaces/serial/SerialTransport.ts new file mode 100644 index 0000000..039bc14 --- /dev/null +++ b/src/interfaces/serial/SerialTransport.ts @@ -0,0 +1,184 @@ +import type { PrinterInfo, PrinterLanguage } from '../../types' +import type { PrinterTransport } from '../PrinterTransport' +import { normalizeConnectError, normalizeOpenError, normalizePrintError, toPrinterError } from '../printerErrors' + +/** true if this browser exposes the Web Serial API. Never throws. */ +export function isSerialSupported(): boolean { + return typeof navigator !== 'undefined' && 'serial' in navigator +} + +/** Overrides for `SerialPort.open()`, merged over DEFAULT_SERIAL_OPTIONS below. */ +export type SerialConnectOptions = Partial + +const DEFAULT_SERIAL_OPTIONS: SerialOptions = { + baudRate: 9600, + dataBits: 8, + stopBits: 1, + parity: 'none', + bufferSize: 255, + flowControl: 'none', +} + +/** Identifies a previously granted port for reconnect() — see SerialPortInfo. */ +export interface SerialPortIdentity { + usbVendorId?: number + usbProductId?: number +} + +/** + * Talks to a printer over the virtual COM port its USB cable exposes (Web + * Serial), instead of Web Bluetooth or QZ Tray — the transport confirmed + * to reliably reach USB thermal printers across Windows/Linux/macOS with + * no extra software installed. WebUSB, by contrast, is blocked outright + * the moment another driver has already claimed the device — an OS + * printer driver on Windows, or the kernel's own `usblp` module on Linux + * (see ../usb/UsbTransport.ts) — but a device's virtual serial port stays + * reachable either way. See AGENTS.md gotcha #11. + * + * Ported independently from reading + * github.com/NielsLeenheer/WebSerialReceiptPrinter's `main.js` (read in + * full from its `main` branch) — same wire defaults (9600 baud, 8 data + * bits, 1 stop bit, no parity, no flow control) and the same choice to + * pass **no filters** to the device picker (any COM port is selectable; + * unlike Bluetooth/WebUSB, Web Serial has no vendor/product-ID-based + * printer-profile concept to filter by in the first place) and **no write + * chunking** (the underlying WritableStream's own backpressure paces + * writes) — this project no longer depends on that npm package, this file + * replaces it. Unlike that reference, there's no background read loop/ + * `data` event here: nothing in this project parses printer status + * responses today (same scope limit as the Bluetooth transports). + * + * `language`/`codepageMapping` can't be auto-detected over serial — a + * SerialPort only reports a USB vendor/product id (SerialPortInfo), not a + * product name, and this project doesn't ship a vendor/product-id table for + * serial the way UsbTransport.ts does for WebUSB (a serial device's + * vendor/product id says which USB-to-serial bridge chip is on the cable, + * not which printer is attached). `language`/`codepageMapping` therefore + * always come from whatever this instance was constructed with — same as + * QzTransport. + */ +export class SerialTransport implements PrinterTransport { + private port: SerialPort | null = null + private writer: WritableStreamDefaultWriter | null = null + private info: PrinterInfo | null = null + + constructor(private readonly reported: { language: PrinterLanguage; codepageMapping?: unknown }) {} + + getInfo(): PrinterInfo | null { + return this.info + } + + /** Shows the native port picker (must be called from a user gesture) and opens whatever the user selects. */ + async connect(options?: SerialConnectOptions): Promise { + if (!isSerialSupported()) { + throw toPrinterError('unsupported', 'This browser does not support Web Serial.') + } + + try { + const port = await navigator.serial.requestPort() + return await this.open(port, options) + } catch (error) { + this.reset() + throw normalizeConnectError(error) + } + } + + /** + * Re-opens a port the user already granted access to in a previous + * session, with no picker prompt — matched by the vendor/product id + * `getInfo()` reported back then (store `PrinterInfo.id` — see the class + * docblock — and split it back into `{ usbVendorId, usbProductId }` to + * pass in here). Resolves to `null`, not an error, when nothing matches + * (nothing to silently reconnect to); the caller should fall back to a + * normal `connect()` in that case. + */ + async reconnect(previous: SerialPortIdentity, options?: SerialConnectOptions): Promise { + if (!isSerialSupported() || previous.usbVendorId === undefined || previous.usbProductId === undefined) { + return null + } + + const ports = await navigator.serial.getPorts() + const match = ports.find((port) => { + const info = port.getInfo() + return info.usbVendorId === previous.usbVendorId && info.usbProductId === previous.usbProductId + }) + if (!match) return null + + try { + return await this.open(match, options) + } catch (error) { + this.reset() + throw normalizeConnectError(error) + } + } + + private async open(port: SerialPort, options?: SerialConnectOptions): Promise { + try { + await port.open({ ...DEFAULT_SERIAL_OPTIONS, ...options }) + } catch (error) { + // A port already picked in the browser's own dialog can still fail + // to open — not a missing-user-gesture problem (that's only true for + // requestPort() itself, above), the port is more likely already in + // use by another app or OS service. + throw normalizeOpenError(error, 'the port may already be in use by another app or OS service') + } + + const { usbVendorId, usbProductId } = port.getInfo() + const id = + usbVendorId !== undefined && usbProductId !== undefined + ? `${usbVendorId.toString(16).padStart(4, '0')}:${usbProductId.toString(16).padStart(4, '0')}` + : 'serial' + + const info: PrinterInfo = { + type: 'serial', + name: `Serial printer (${id})`, + id, + language: this.reported.language, + codepageMapping: this.reported.codepageMapping, + } + this.port = port + this.info = info + return info + } + + async disconnect(): Promise { + try { + // A held writer must release its lock before the port can close — + // the Web Serial API throws otherwise. + this.writer?.releaseLock() + await this.port?.close() + } catch { + // Already closing/closed — nothing actionable, mirrors the other + // transports' fire-and-forget disconnect(). + } + this.reset() + } + + isConnected(): boolean { + return this.info !== null + } + + async print(bytes: Uint8Array): Promise { + if (!this.port || !this.info) { + throw toPrinterError('not-connected', 'Call connect() before printing.') + } + + try { + // Acquired once and kept for the life of the connection — matches + // the reference implementation, which relies on the stream's own + // backpressure rather than chunking writes by hand. + const writer = this.writer ?? this.port.writable?.getWriter() + if (!writer) throw new Error('Serial port has no writable stream.') + this.writer = writer + await writer.write(bytes) + } catch (error) { + throw normalizePrintError(error) + } + } + + private reset(): void { + this.writer = null + this.port = null + this.info = null + } +} diff --git a/src/interfaces/usb/UsbTransport.ts b/src/interfaces/usb/UsbTransport.ts new file mode 100644 index 0000000..a75c822 --- /dev/null +++ b/src/interfaces/usb/UsbTransport.ts @@ -0,0 +1,188 @@ +import type { PrinterInfo } from '../../types' +import type { PrinterTransport } from '../PrinterTransport' +import { normalizeConnectError, normalizeOpenError, normalizePrintError, toPrinterError } from '../printerErrors' +import { ALL_FILTERS, evaluate, findUsbProfile, type UsbPrinterProfile } from './profiles' + +/** true if this browser exposes the WebUSB API. Never throws. */ +export function isUsbSupported(): boolean { + return typeof navigator !== 'undefined' && 'usb' in navigator +} + +/** Identifies a previously granted device for reconnect() — see USBDevice. */ +export interface UsbDeviceIdentity { + serialNumber?: string | null + vendorId: number + productId: number +} + +/** + * Talks directly to a printer's USB bulk endpoint (WebUSB), instead of Web + * Bluetooth, QZ Tray, or Web Serial. The cleanest transport where it works + * — no protocol translation, no virtual port — but **not reliably + * available on any OS without extra setup**: the moment another driver has + * already claimed the device, `claimInterface()` throws outright with no + * code-level fix — confirmed on real hardware happening on both Windows + * (an OS printer driver) and Linux (the kernel's own `usblp` module, + * which auto-binds any USB Printer-Class device on plug-in, or a missing + * udev permission rule). Use ../serial/SerialTransport.ts instead by + * default — confirmed reliable across OSes with no such setup needed. See + * AGENTS.md gotcha #11. + * + * Ported independently from reading + * github.com/NielsLeenheer/WebUSBReceiptPrinter's `main.js` (read in full + * from its `master` branch) — same open sequence (`open()` -> + * `selectConfiguration()` -> `claimInterface()` -> find the active alternate + * setting's `direction: 'out'` endpoint -> `device.reset()`) and the same + * choice of **no write chunking** (a single `transferOut()` per `print()` + * call) — this project no longer depends on that npm package, this file + * replaces it, with its own vendor/product-id profile table (./profiles.ts) + * instead of that package's. Unlike that reference, there's no background + * IN-endpoint read loop/`data` event here: nothing in this project parses + * printer status responses today (same scope limit as the Bluetooth + * transports). + */ +export class UsbTransport implements PrinterTransport { + private device: USBDevice | null = null + private endpoint: number | null = null + private info: PrinterInfo | null = null + + getInfo(): PrinterInfo | null { + return this.info + } + + /** + * Shows the native device picker (must be called from a user gesture), + * restricted to `profiles.ts`'s known vendor/product ids unless + * `manualProfile` is given — see UsbPrinterProfile (./profiles.ts) for + * the full field shape, same escape-hatch pattern as Bluetooth's manual + * profile (README's "Manual Bluetooth profile" section, works + * identically here). + */ + async connect(manualProfile?: UsbPrinterProfile): Promise { + if (!isUsbSupported()) { + throw toPrinterError('unsupported', 'This browser does not support WebUSB.') + } + + try { + const device = await navigator.usb.requestDevice({ + filters: manualProfile ? manualProfile.filters : ALL_FILTERS, + }) + return await this.open(device, manualProfile) + } catch (error) { + this.reset() + throw normalizeConnectError(error) + } + } + + /** + * Re-opens a device the user already granted access to in a previous + * session, with no picker prompt — matched first by `serialNumber` (most + * reliable, when the device reports one), falling back to vendor/product + * id. Resolves to `null`, not an error, when nothing matches; the caller + * should fall back to a normal `connect()` in that case. + */ + async reconnect(previous: UsbDeviceIdentity, manualProfile?: UsbPrinterProfile): Promise { + if (!isUsbSupported()) return null + + const devices = await navigator.usb.getDevices() + const match = + (previous.serialNumber ? devices.find((device) => device.serialNumber === previous.serialNumber) : undefined) ?? + devices.find((device) => device.vendorId === previous.vendorId && device.productId === previous.productId) + if (!match) return null + + try { + return await this.open(match, manualProfile) + } catch (error) { + this.reset() + throw normalizeConnectError(error) + } + } + + private async open(device: USBDevice, manualProfile?: UsbPrinterProfile): Promise { + const profile = manualProfile ?? findUsbProfile(device) + if (!profile) { + throw toPrinterError( + 'connect-failed', + `Printer "${device.productName ?? 'Unknown'}" (${device.vendorId.toString(16)}:${device.productId.toString(16)}) has no known USB printer profile.`, + ) + } + + try { + await device.open() + await device.selectConfiguration(profile.configuration) + await device.claimInterface(profile.interface) + + const iface = device.configuration?.interfaces.find((i) => i.interfaceNumber === profile.interface) + const outEndpoint = iface?.alternate.endpoints.find((endpoint) => endpoint.direction === 'out') + if (!outEndpoint) { + throw toPrinterError('connect-failed', `Printer "${device.productName ?? 'Unknown'}" has no USB OUT endpoint on interface ${profile.interface}.`) + } + + // Matches the reference implementation — issued right after claiming, + // before the device is reported connected. + await device.reset() + + const id = device.serialNumber || `${device.vendorId.toString(16).padStart(4, '0')}:${device.productId.toString(16).padStart(4, '0')}` + const info: PrinterInfo = { + type: 'usb', + name: device.productName ?? 'USB printer', + id, + language: evaluate(profile.language, device), + codepageMapping: evaluate(profile.codepageMapping, device), + } + this.device = device + this.endpoint = outEndpoint.endpointNumber + this.info = info + return info + } catch (error) { + // A device already picked in the browser's own dialog can still fail + // here with e.g. SecurityError — confirmed on real hardware (both + // Windows and Linux) to mean "another driver already claimed this + // device", NOT "missing user gesture" (that's only true for + // requestDevice() itself, above). Linux-specific console.warn() with + // exact fix steps lives in normalizeOpenError() (printerErrors.ts) — + // shared, not duplicated per transport. + throw normalizeOpenError( + error, + 'likely claimed by another driver (an OS printer driver on Windows, or the kernel\'s usblp module on Linux) — WebUSB can\'t override that; try connect({ transport: "serial" }) instead', + ) + } + } + + async disconnect(): Promise { + try { + await this.device?.close() + } catch { + // Already closing/closed — nothing actionable, mirrors the other + // transports' fire-and-forget disconnect(). + } + this.reset() + } + + isConnected(): boolean { + return this.info !== null + } + + async print(bytes: Uint8Array): Promise { + if (!this.device || this.endpoint === null || !this.info) { + throw toPrinterError('not-connected', 'Call connect() before printing.') + } + + try { + // `new Uint8Array(bytes)` (not `bytes` directly) — a generic + // Uint8Array's `.buffer` is typed ArrayBufferLike (could be a + // SharedArrayBuffer), which WebUSB's stricter ArrayBuffer-only + // BufferSource typing rejects; the copy constructor always allocates + // a real ArrayBuffer regardless of the source's backing. + await this.device.transferOut(this.endpoint, new Uint8Array(bytes)) + } catch (error) { + throw normalizePrintError(error) + } + } + + private reset(): void { + this.device = null + this.endpoint = null + this.info = null + } +} diff --git a/src/interfaces/usb/profiles.ts b/src/interfaces/usb/profiles.ts new file mode 100644 index 0000000..81fa59c --- /dev/null +++ b/src/interfaces/usb/profiles.ts @@ -0,0 +1,156 @@ +import type { PrinterLanguage } from '../../types' + +export interface UsbPrinterProfile { + /** + * OR'd list of device-picker filters (same shape as WebUSB's own + * `requestDevice({ filters })`). A device matches the profile if it + * satisfies *any* entry in this array. + */ + filters: USBDeviceFilter[] + /** USBConfiguration.configurationValue to select after opening the device. */ + configuration: number + /** Interface number to claim, on the selected configuration. */ + interface: number + language: PrinterLanguage | ((device: USBDevice) => PrinterLanguage) + codepageMapping: unknown | ((device: USBDevice) => unknown) +} + +/** Resolves a profile's `language`/`codepageMapping` field, which may be a plain value or a per-device resolver function. */ +export function evaluate(field: T | ((device: USBDevice) => T), device: USBDevice): T { + return typeof field === 'function' ? (field as (device: USBDevice) => T)(device) : field +} + +/** + * Star's own USB vendor id covers several printer families that speak + * different protocols — told apart only by `productName` once connected, + * not by product id. This is a deliberately simplified resolver (not a + * verbatim port — WebUSBReceiptPrinter's own regex-based model-normalizing + * table couldn't be reproduced here, see usb/profiles.ts's docblock below) + * covering the common current lines; anything unrecognized falls back to + * `star-line`, the oldest/most broadly implemented Star protocol. Printers + * that need the exact upstream mapping can be connected via `connect({ + * transport: 'usb', profile })` with a hand-built UsbPrinterProfile instead + * — see the README's "Manual Bluetooth profile" section for the same + * pattern (works identically for USB). + */ +function resolveStarLanguage(device: USBDevice): PrinterLanguage { + const name = device.productName ?? '' + if (/^(TSP100IV|mPOP|mC-Label3|mC-Print[23])/i.test(name)) return 'star-prnt' + if (/^(BSC10)/i.test(name)) return 'esc-pos' + return 'star-line' +} + +/** + * Every known USB printer profile. Vendor/product ids ported from + * github.com/NielsLeenheer/WebUSBReceiptPrinter's `DeviceProfiles` table + * (read in full from its `master` branch) — this project no longer depends + * on that npm package, this table replaces it. Every profile there hard- + * codes `configuration: 1, interface: 0`, carried over as-is here. + * + * To add a new printer, append a profile here — UsbTransport.ts itself + * doesn't need to change. + */ +export const USB_PROFILES: UsbPrinterProfile[] = [ + // Zjiang POS-5805 / POS-8360 and similar. + { + filters: [{ vendorId: 0x0416, productId: 0x5011 }], + configuration: 1, + interface: 0, + language: 'esc-pos', + codepageMapping: 'zjiang', + }, + // MTP-II clone. + { + filters: [{ vendorId: 0x0483, productId: 0x5840 }], + configuration: 1, + interface: 0, + language: 'esc-pos', + codepageMapping: 'mpt', + }, + // POS-8022 and similar STMicroelectronics-based clones. + { + filters: [{ vendorId: 0x0483, productId: 0x5743 }], + configuration: 1, + interface: 0, + language: 'esc-pos', + codepageMapping: 'default', + }, + // Dtronic. + { + filters: [{ vendorId: 0x0fe6, productId: 0x811e }], + configuration: 1, + interface: 0, + language: 'esc-pos', + codepageMapping: 'epson', + }, + // Xprinter. + { + filters: [{ vendorId: 0x1fc9, productId: 0x2016 }], + configuration: 1, + interface: 0, + language: 'esc-pos', + codepageMapping: 'xprinter', + }, + // Samsung SRP series — vendor-only, no single product id across the line. + { + filters: [{ vendorId: 0x0419 }, { vendorId: 0x1504 }], + configuration: 1, + interface: 0, + language: 'esc-pos', + codepageMapping: 'bixolon', + }, + // Epson TM-* line — vendor-only. + { + filters: [{ vendorId: 0x04b8 }], + configuration: 1, + interface: 0, + language: 'esc-pos', + codepageMapping: 'epson', + }, + // Citizen — vendor-only. + { + filters: [{ vendorId: 0x1d90 }], + configuration: 1, + interface: 0, + language: 'esc-pos', + codepageMapping: 'citizen', + }, + // HP — vendor-only. + { + filters: [{ vendorId: 0x05d9 }], + configuration: 1, + interface: 0, + language: 'esc-pos', + codepageMapping: 'hp', + }, + // Fujitsu — vendor-only. + { + filters: [{ vendorId: 0x04c5 }], + configuration: 1, + interface: 0, + language: 'esc-pos', + codepageMapping: 'epson', + }, + // Star — vendor-only, protocol resolved per-device from productName (see resolveStarLanguage above). + { + filters: [{ vendorId: 0x0519 }], + configuration: 1, + interface: 0, + language: resolveStarLanguage, + codepageMapping: 'star', + }, +] + +/** Every filter across all profiles, flattened — passed to `requestDevice({ filters })` by the default (no manual profile) connect path. */ +export const ALL_FILTERS = USB_PROFILES.flatMap((profile) => profile.filters) + +function matchesFilter(filter: USBDeviceFilter, device: USBDevice): boolean { + if (filter.vendorId !== undefined && filter.vendorId !== device.vendorId) return false + if (filter.productId !== undefined && filter.productId !== device.productId) return false + return true +} + +/** First profile whose filters match the connected device's vendor/product id. Used by UsbTransport.ts after requestDevice() resolves. */ +export function findUsbProfile(device: USBDevice): UsbPrinterProfile | null { + return USB_PROFILES.find((profile) => profile.filters.some((filter) => matchesFilter(filter, device))) ?? null +} diff --git a/src/types.ts b/src/types.ts index 0425a39..240faec 100644 --- a/src/types.ts +++ b/src/types.ts @@ -46,9 +46,15 @@ export interface WebEscposPrinterConfig { export type WebEscposPrinterConfigInput = Partial & { paperWidth?: PaperWidth } export interface PrinterInfo { - type: 'bluetooth' | 'qz' + type: 'bluetooth' | 'qz' | 'serial' | 'usb' name: string - /** For type: 'qz', this is just the QZ printer name — QZ has no separate device id the way Bluetooth's device.id does. */ + /** + * For type: 'qz', this is just the QZ printer name — QZ has no separate + * device id the way Bluetooth's device.id does. For type: 'serial'/'usb', + * it's a vendor:product identifier (Web Serial has no persistent device + * id at all; WebUSB's serialNumber is preferred when the device reports + * one, see UsbTransport.ts). + */ id: string language: 'esc-pos' | 'star-prnt' | 'star-line' codepageMapping?: unknown diff --git a/test/interfaces/usb/profiles.test.ts b/test/interfaces/usb/profiles.test.ts new file mode 100644 index 0000000..619ecfc --- /dev/null +++ b/test/interfaces/usb/profiles.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest' +import { ALL_FILTERS, USB_PROFILES, evaluate, findUsbProfile } from '../../../src/interfaces/usb/profiles' + +/** + * `USBDevice` (from @types/w3c-web-usb) declares only public readonly + * fields/methods, so a plain object literal satisfies it structurally — + * no real WebUSB API needed to unit test findUsbProfile()/evaluate(), + * unlike UsbTransport.ts itself (not covered — see AGENTS.md's "Testing" + * section). + */ +function fakeUsbDevice(overrides: Partial): USBDevice { + return { + vendorId: 0, + productId: 0, + productName: null, + manufacturerName: null, + serialNumber: null, + ...overrides, + } as USBDevice +} + +describe('usb/profiles: findUsbProfile()', () => { + it('matches a vendor+product-id-specific profile (Zjiang POS-5805/8360)', () => { + const profile = findUsbProfile(fakeUsbDevice({ vendorId: 0x0416, productId: 0x5011 })) + expect(profile?.codepageMapping).toBe('zjiang') + }) + + it('matches a vendor-only profile regardless of product id (Epson)', () => { + const profile = findUsbProfile(fakeUsbDevice({ vendorId: 0x04b8, productId: 0x1234 })) + expect(profile?.codepageMapping).toBe('epson') + }) + + it('returns null for an unrecognized vendor id', () => { + expect(findUsbProfile(fakeUsbDevice({ vendorId: 0xffff, productId: 0xffff }))).toBeNull() + }) + + it('every profile in the table is reachable through ALL_FILTERS (used to pre-filter the device picker)', () => { + for (const profile of USB_PROFILES) { + for (const filter of profile.filters) { + expect(ALL_FILTERS).toContain(filter) + } + } + }) +}) + +describe('usb/profiles: evaluate()', () => { + it('returns a plain value as-is', () => { + expect(evaluate('esc-pos', fakeUsbDevice({}))).toBe('esc-pos') + }) + + it('calls a resolver function with the device and returns its result (Star language resolution)', () => { + const starProfile = findUsbProfile(fakeUsbDevice({ vendorId: 0x0519, productId: 0x1234 })) + expect(starProfile).not.toBeNull() + + expect(evaluate(starProfile!.language, fakeUsbDevice({ productName: 'mC-Print3' }))).toBe('star-prnt') + expect(evaluate(starProfile!.language, fakeUsbDevice({ productName: 'BSC10' }))).toBe('esc-pos') + expect(evaluate(starProfile!.language, fakeUsbDevice({ productName: 'TSP654II' }))).toBe('star-line') + expect(evaluate(starProfile!.language, fakeUsbDevice({ productName: null }))).toBe('star-line') + }) +}) diff --git a/test/tsconfig.json b/test/tsconfig.json index b6c044d..88c5643 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../tsconfig.json", "compilerOptions": { - "types": ["node", "web-bluetooth"], + "types": ["node", "web-bluetooth", "w3c-web-serial", "w3c-web-usb"], "noEmit": true }, "include": ["**/*.ts", "../src/**/*.ts", "../config.ts", "../index.ts"] diff --git a/tsconfig.json b/tsconfig.json index 693fade..7fa1809 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,7 @@ "module": "ESNext", "moduleResolution": "Bundler", "lib": ["ES2020", "DOM"], - "types": ["web-bluetooth"], + "types": ["web-bluetooth", "w3c-web-serial", "w3c-web-usb"], "strict": true, "esModuleInterop": true, "skipLibCheck": true,