Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/device/profiles/p15.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const p15Profile: DeviceProfile = {
packetDelayMs: 30,
},
defaults: { density: 2, paperType: "gap" },
densityCommand: "thickness",
namePrefixes: [
"P15",
"P15R",
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/device/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export interface DeviceProfile {
packetSize?: number;
flowControl: Partial<FlowControlOptions>;
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;
}
Expand Down
133 changes: 116 additions & 17 deletions packages/core/src/printer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export class Printer {
private readonly listeners = new Map<string, Set<EventListener<any>>>();
private flowController: FlowController;
private disconnecting = false;
private _printing = false;

private constructor(
private readonly connection: BleConnection,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -127,37 +135,80 @@ export class Printer {
): Promise<void> {
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<PrinterStatus> {
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);
Expand All @@ -168,6 +219,7 @@ export class Printer {
}

async getBattery(): Promise<number> {
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);
Expand All @@ -178,6 +230,7 @@ export class Printer {
}

async getModel(): Promise<string> {
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);
Expand All @@ -186,6 +239,7 @@ export class Printer {
}

async getInfo(type: "firmware" | "serial" | "mac" | "bt-version" | "bt-name" | "speed"): Promise<string> {
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);
Expand Down Expand Up @@ -295,6 +349,51 @@ export class Printer {
}
}

private waitForCredits(minCredits = 3, timeoutMs = 1000): Promise<void> {
if (this.flowController.availableCredits >= minCredits) {
return Promise.resolve();
}
return new Promise<void>((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<void> {
if (this.flowController.availableCredits > 0) {
return Promise.resolve();
}
return new Promise<void>((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<PrinterResponse> {
return new Promise<PrinterResponse>((resolve, reject) => {
const timer = setTimeout(() => {
Expand Down
8 changes: 6 additions & 2 deletions packages/core/src/protocol/l11/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/protocol/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export interface PrinterResponse {

export interface PrintSequenceOptions {
density?: number;
densityCommand?: "density" | "thickness";
paperType?: "gap" | "continuous";
copies?: number;
}
Expand Down
36 changes: 25 additions & 11 deletions packages/core/src/transport/flow-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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<void> {
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;
Expand All @@ -59,32 +69,36 @@ 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);
const chunk = data.subarray(offset, offset + chunkSize);

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);
Expand Down
25 changes: 5 additions & 20 deletions packages/web/src/editor/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
10 changes: 4 additions & 6 deletions packages/web/src/hooks/use-web-bluetooth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,25 +73,23 @@ 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) => ({
deviceInfo: { ...s.deviceInfo, [key]: val },
}));
}
} catch {
// Non-critical
// Non-critical — stop querying on first failure (printer may not support it)
break;
}
}
} catch (err) {
Expand Down
Loading