diff --git a/packages/core/src/device/profiles/p15.ts b/packages/core/src/device/profiles/p15.ts index 9c56765..89b02a5 100644 --- a/packages/core/src/device/profiles/p15.ts +++ b/packages/core/src/device/profiles/p15.ts @@ -27,6 +27,7 @@ export const p15Profile: DeviceProfile = { packetDelayMs: 30, }, defaults: { density: 2, paperType: "gap" }, + densityCommand: "thickness", namePrefixes: [ "P15", "P15R", diff --git a/packages/core/src/device/types.ts b/packages/core/src/device/types.ts index 941e39e..a3b1dc1 100644 --- a/packages/core/src/device/types.ts +++ b/packages/core/src/device/types.ts @@ -24,6 +24,8 @@ export interface DeviceProfile { packetSize?: number; flowControl: Partial; defaults: { density: number; paperType: "gap" | "continuous" }; + /** Which command to use for print darkness: "density" (1F 70 02) or "thickness" (10 FF 10 00) */ + densityCommand?: "density" | "thickness"; namePrefixes: string[]; labelConfig?: DeviceLabelConfig; } diff --git a/packages/core/src/printer.ts b/packages/core/src/printer.ts index 25190c3..f03660a 100644 --- a/packages/core/src/printer.ts +++ b/packages/core/src/printer.ts @@ -28,6 +28,7 @@ export class Printer { private readonly listeners = new Map>>(); private flowController: FlowController; private disconnecting = false; + private _printing = false; private constructor( private readonly connection: BleConnection, @@ -97,6 +98,13 @@ export class Printer { // Subscribe to CX for flow control / MTU if (cx) { await cx.subscribe((data) => printer.handleCxData(data)); + + // Wait for initial credits from the printer before returning. + // The printer sends [0x01, N] on CX shortly after connection to grant + // initial flow-control credits. Without this, the first print would + // start with 0 credits and rely on starvation recovery (1 pkt/sec), + // causing partial data to reach the printer before the full bitmap. + await printer.waitForInitialCredits(); } // Emit disconnected event on unexpected BLE link loss @@ -127,37 +135,80 @@ export class Printer { ): Promise { const mergedOptions = { density: options.density ?? this.profile.defaults.density, + densityCommand: this.profile.densityCommand, paperType: options.paperType ?? this.profile.defaults.paperType, copies: options.copies, }; const commands = this.protocol.buildPrintSequence(image, mergedOptions); - const totalBytes = commands.reduce((sum, cmd) => sum + cmd.data.length, 0); - let bytesSent = 0; - - debugLog("PRINT", `start ${image.width}x${image.height} ${totalBytes}B density=${mergedOptions.density} paper=${mergedOptions.paperType}`); + // Split commands into preamble (setup) and bulk (bitmap + trailing). + // Send preamble first, then wait for credits to refill before sending + // bulk data. This prevents the printer from starting to print with only + // a few rows buffered — the thermal head warmup during preamble processing + // gives time for credits to replenish so bulk data flows without gaps. + const preamble: Uint8Array[] = []; + const bulk: Uint8Array[] = []; + let seenBulk = false; for (const command of commands) { - if (command.bulk) { - await this.flowController.send(command.data, (sent) => { - this.emit("progress", { - bytesSent: bytesSent + sent, - totalBytes, - }); - }); + if (command.bulk) seenBulk = true; + if (seenBulk) { + bulk.push(command.data); } else { - await this.flowController.send(command.data); + preamble.push(command.data); + } + } + + const preambleBytes = preamble.reduce((sum, d) => sum + d.length, 0); + const bulkPayload = new Uint8Array(bulk.reduce((sum, d) => sum + d.length, 0)); + let bulkOffset = 0; + for (const d of bulk) { + bulkPayload.set(d, bulkOffset); + bulkOffset += d.length; + } + + const totalBytes = preambleBytes + bulkPayload.length; + + debugLog("PRINT", `start ${image.width}x${image.height} ${totalBytes}B (preamble=${preambleBytes}B bulk=${bulkPayload.length}B) density=${mergedOptions.density} paper=${mergedOptions.paperType}`); + + this._printing = true; + try { + // Send preamble (wakeup, enable, density) — small setup commands + if (preambleBytes > 0) { + const preamblePayload = new Uint8Array(preambleBytes); + let off = 0; + for (const d of preamble) { + preamblePayload.set(d, off); + off += d.length; + } + await this.flowController.send(preamblePayload); + debugLog("PRINT", "preamble sent, waiting for credits before bitmap"); + + // Wait for credits to refill — the printer processes the setup commands + // and grants credits back. This ensures the bitmap starts with full + // credits so data flows continuously without visible gaps. + await this.waitForCredits(); } - bytesSent += command.data.length; - this.emit("progress", { bytesSent, totalBytes }); + + // Send bulk data (bitmap + trailing commands like positionToGap, stop) + await this.flowController.send(bulkPayload, (sent) => { + this.emit("progress", { bytesSent: preambleBytes + sent, totalBytes }); + }); + + debugLog("PRINT", "data sent, waiting for result"); + await this.waitForPrintResult(); + debugLog("PRINT", "done"); + } finally { + this._printing = false; } + } - debugLog("PRINT", "data sent, waiting for result"); - await this.waitForPrintResult(); - debugLog("PRINT", "done"); + get isPrinting(): boolean { + return this._printing; } async getStatus(timeoutMs = 5000): Promise { + if (this._printing) throw new ThermoprintError(ErrorCode.PRINT_FAILED, "Cannot query status while printing"); const cmd = this.protocol.buildStatusQuery(); await this.flowController.send(cmd.data); const response = await this.waitForResponse("status", timeoutMs); @@ -168,6 +219,7 @@ export class Printer { } async getBattery(): Promise { + if (this._printing) throw new ThermoprintError(ErrorCode.PRINT_FAILED, "Cannot query battery while printing"); const cmd = this.protocol.buildBatteryQuery(); await this.flowController.send(cmd.data); const response = await this.waitForResponse("battery", 3000); @@ -178,6 +230,7 @@ export class Printer { } async getModel(): Promise { + if (this._printing) throw new ThermoprintError(ErrorCode.PRINT_FAILED, "Cannot query model while printing"); const cmd = this.protocol.buildModelQuery(); await this.flowController.send(cmd.data); const response = await this.waitForResponse("model", 3000); @@ -186,6 +239,7 @@ export class Printer { } async getInfo(type: "firmware" | "serial" | "mac" | "bt-version" | "bt-name" | "speed"): Promise { + if (this._printing) throw new ThermoprintError(ErrorCode.PRINT_FAILED, "Cannot query info while printing"); const cmd = this.protocol.buildInfoQuery(type); await this.flowController.send(cmd.data); const response = await this.waitForResponse(type, 3000); @@ -295,6 +349,51 @@ export class Printer { } } + private waitForCredits(minCredits = 3, timeoutMs = 1000): Promise { + if (this.flowController.availableCredits >= minCredits) { + return Promise.resolve(); + } + return new Promise((resolve) => { + const timer = setTimeout(() => { + debugLog("FC", `credit wait timeout (have ${this.flowController.availableCredits}, wanted ${minCredits}) — proceeding`); + resolve(); + }, timeoutMs); + + const check = () => { + if (this.flowController.availableCredits >= minCredits) { + clearTimeout(timer); + debugLog("FC", `credits ready: ${this.flowController.availableCredits}`); + resolve(); + } else { + setTimeout(check, 20); + } + }; + check(); + }); + } + + private waitForInitialCredits(timeoutMs = 3000): Promise { + if (this.flowController.availableCredits > 0) { + return Promise.resolve(); + } + return new Promise((resolve) => { + const timer = setTimeout(() => { + debugLog("FC", "initial credit timeout — proceeding without credits"); + resolve(); + }, timeoutMs); + + const check = () => { + if (this.flowController.availableCredits > 0) { + clearTimeout(timer); + resolve(); + } else { + setTimeout(check, 50); + } + }; + check(); + }); + } + private waitForResponse(type: string, timeoutMs = 5000): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => { diff --git a/packages/core/src/protocol/l11/protocol.ts b/packages/core/src/protocol/l11/protocol.ts index c0d9913..237cc9d 100644 --- a/packages/core/src/protocol/l11/protocol.ts +++ b/packages/core/src/protocol/l11/protocol.ts @@ -22,11 +22,15 @@ export class L11Protocol implements PrinterProtocol { image: ImageBitmap1bpp, options: PrintSequenceOptions = {}, ): PrintCommand[] { - const { density, paperType = "gap" } = options; + const { density, densityCommand = "density", paperType = "gap" } = options; const commands: PrintCommand[] = []; if (density !== undefined) { - commands.push(cmd.setDensity(density)); + commands.push( + densityCommand === "thickness" + ? cmd.setThickness(density) + : cmd.setDensity(density), + ); } commands.push(cmd.wakeup()); commands.push(cmd.enable()); diff --git a/packages/core/src/protocol/types.ts b/packages/core/src/protocol/types.ts index a4696cb..08e694c 100644 --- a/packages/core/src/protocol/types.ts +++ b/packages/core/src/protocol/types.ts @@ -27,6 +27,7 @@ export interface PrinterResponse { export interface PrintSequenceOptions { density?: number; + densityCommand?: "density" | "thickness"; paperType?: "gap" | "continuous"; copies?: number; } diff --git a/packages/core/src/transport/flow-control.ts b/packages/core/src/transport/flow-control.ts index 05993cb..75480f4 100644 --- a/packages/core/src/transport/flow-control.ts +++ b/packages/core/src/transport/flow-control.ts @@ -11,6 +11,9 @@ export class FlowController { private credits: number = 0; private readonly options: FlowControlOptions; private lastCreditTime: number = Date.now(); + private sendStartTime: number = 0; + private packetsSent: number = 0; + private loggedNoCredits: boolean = false; private packetSize: number; constructor( @@ -29,23 +32,30 @@ export class FlowController { grantCredits(count: number): void { this.credits += count; this.lastCreditTime = Date.now(); - debugLog("FC", `+${count} credits, total=${this.credits}`); + this.loggedNoCredits = false; + debugLog("FC", `+${count} credits, total=${this.credits}, @${Date.now() - this.sendStartTime}ms`); } /** * Send data through the BLE characteristic with credit-based flow control. * - * Matches the official Marklife app's timer-based approach: a periodic timer - * fires at a fixed interval (e.g. 30ms for P15). On each tick, at most one - * packet is sent if credits are available. If credits have been exhausted for - * longer than the starvation timeout, one credit is forced unconditionally - * (matching the official app's recovery logic). + * Matches the official Marklife app exactly: a periodic timer fires at a + * fixed interval (e.g. 30ms for P15). On each tick, exactly ONE packet is + * sent if credits are available. This pacing is critical — sending faster + * overwhelms the printer's BLE stack and it stops granting credits. + * + * If credits have been exhausted for longer than the starvation timeout, + * one credit is forced unconditionally (matching the official app's recovery). */ async send( data: Uint8Array, onProgress?: (bytesSent: number) => void, ): Promise { let offset = 0; + this.sendStartTime = Date.now(); + this.lastCreditTime = Date.now(); + this.packetsSent = 0; + this.loggedNoCredits = false; debugLog("TX", `sending ${data.length}B in ${Math.ceil(data.length / this.packetSize)} packets, credits=${this.credits}`); const { packetDelayMs, starvationTimeoutMs } = this.options; @@ -59,17 +69,14 @@ export class FlowController { return; } - // Starvation recovery: force 1 credit unconditionally after timeout, - // matching the official app. This keeps data flowing even when BLE - // notifications are lost (Web Bluetooth) or the printer is slow to - // grant credits. + // Starvation recovery if (this.credits <= 0 && Date.now() - this.lastCreditTime >= starvationTimeoutMs) { debugLog("FC", `starvation recovery, forcing 1 credit`); this.credits = 1; this.lastCreditTime = Date.now(); } - // Send at most one packet per tick (matching official app timer cadence) + // Send exactly one packet per tick (matching official app pacing) if (this.credits > 0) { const remaining = data.length - offset; const chunkSize = Math.min(remaining, this.packetSize); @@ -77,14 +84,21 @@ export class FlowController { await this.tx.write(chunk, true); // withoutResponse = true this.credits--; + this.packetsSent++; offset += chunkSize; + debugLog("TX", `pkt#${this.packetsSent} ${chunkSize}B sent=${offset}/${data.length} credits=${this.credits} @${Date.now() - this.sendStartTime}ms`); + onProgress?.(offset); if (offset >= data.length) { + debugLog("TX", `done in ${Date.now() - this.sendStartTime}ms`); resolve(); return; } + } else if (!this.loggedNoCredits) { + this.loggedNoCredits = true; + debugLog("FC", `no credits @${Date.now() - this.sendStartTime}ms, pkt#${this.packetsSent}, starvation in ${starvationTimeoutMs - (Date.now() - this.lastCreditTime)}ms`); } setTimeout(tick, tickMs); diff --git a/packages/web/src/editor/editor.tsx b/packages/web/src/editor/editor.tsx index c760fe6..01e2bb4 100644 --- a/packages/web/src/editor/editor.tsx +++ b/packages/web/src/editor/editor.tsx @@ -97,26 +97,11 @@ export function Editor() { const rotatedW = canvas.width; const rotatedH = canvas.height; - // Pad to printWidth if narrower than the print head - const printWidth = settings.printWidth; - let imageData: RawImageData; - - if (rotatedW < printWidth) { - const padded = document.createElement("canvas"); - padded.width = printWidth; - padded.height = rotatedH; - const pCtx = padded.getContext("2d")!; - pCtx.fillStyle = "#ffffff"; - pCtx.fillRect(0, 0, printWidth, rotatedH); - const offsetX = Math.floor((printWidth - rotatedW) / 2); - pCtx.drawImage(canvas, offsetX, 0); - const imgData = pCtx.getImageData(0, 0, printWidth, rotatedH); - imageData = { data: imgData.data, width: printWidth, height: rotatedH }; - } else { - const ctx = canvas.getContext("2d")!; - const imgData = ctx.getImageData(0, 0, rotatedW, rotatedH); - imageData = { data: imgData.data, width: rotatedW, height: rotatedH }; - } + // Send at the label's natural pixel size — no padding to print head width. + // The printer handles positioning; padding would 4x the data for narrow labels. + const ctx = canvas.getContext("2d")!; + const imgData = ctx.getImageData(0, 0, rotatedW, rotatedH); + const imageData: RawImageData = { data: imgData.data, width: rotatedW, height: rotatedH }; // Listen for real progress events from the printer const offProgress = (p: { bytesSent: number; totalBytes: number }) => { diff --git a/packages/web/src/hooks/use-web-bluetooth.ts b/packages/web/src/hooks/use-web-bluetooth.ts index 7cfca91..5d8465d 100644 --- a/packages/web/src/hooks/use-web-bluetooth.ts +++ b/packages/web/src/hooks/use-web-bluetooth.ts @@ -73,17 +73,14 @@ export function useWebBluetooth() { console.warn("[thermoprint] model query failed:", err); } - // Query all device info — each is non-critical, fire sequentially + // Query device info — non-critical, skip any that fail or if a print starts const infoQueries = [ ["firmware", "firmware"], ["serial", "serial"], - ["mac", "mac"], - ["btVersion", "bt-version"], - ["btName", "bt-name"], - ["speed", "speed"], ] as const; for (const [key, type] of infoQueries) { try { + if (printer.isPrinting) break; const val = await printer.getInfo(type as "firmware" | "serial" | "mac" | "bt-version" | "bt-name" | "speed"); if (val) { store.setState((s) => ({ @@ -91,7 +88,8 @@ export function useWebBluetooth() { })); } } catch { - // Non-critical + // Non-critical — stop querying on first failure (printer may not support it) + break; } } } catch (err) {