diff --git a/docs/adding-a-printer.md b/docs/adding-a-printer.md index a3d430e..9706ec5 100644 --- a/docs/adding-a-printer.md +++ b/docs/adding-a-printer.md @@ -21,9 +21,8 @@ export const p20Profile: DeviceProfile = { }, packetSize: 95, // BLE write chunk size in bytes flowControl: { - initialCredits: 4, starvationTimeoutMs: 1000, - timerIntervalMs: 30, + packetDelayMs: 30, }, defaults: { density: 2, paperType: "gap" }, // "gap" for label paper, "continuous" for receipt namePrefixes: ["P20", "P20S"], // BLE advertised name prefixes @@ -53,7 +52,7 @@ That's it. `discover()` will now match peripherals whose name starts with "P20" | `characteristics.rx` | `string` | Notify characteristic for responses | | `characteristics.cx` | `string?` | Notify characteristic for flow control credits (optional) | | `packetSize` | `number?` | Max bytes per BLE write (default: 237) | -| `flowControl` | `Partial` | Override `initialCredits`, `starvationTimeoutMs`, `timerIntervalMs` | +| `flowControl` | `Partial` | Override `starvationTimeoutMs`, `packetDelayMs` | | `defaults.density` | `number` | Default print density (0-3) | | `defaults.paperType` | `"gap" \| "continuous"` | Default paper type | | `namePrefixes` | `string[]` | BLE name prefixes to match during discovery | diff --git a/docs/transport.md b/docs/transport.md index 99f7513..70f7bc7 100644 --- a/docs/transport.md +++ b/docs/transport.md @@ -220,17 +220,15 @@ Thermoprint uses **credit-based flow control** to avoid overwhelming the printer ### How it works -1. **Initial credits.** The `FlowController` starts with `initialCredits` (default: 4). Each credit allows sending one packet. +1. **Credits start at zero.** The `FlowController` starts with no credits. The printer grants the initial credits (typically 4) via a CX notification after connection. Each credit allows sending one packet. -2. **Packet chunking.** Data is split into chunks of `packetSize` bytes (P15: 95 bytes, P12: 90 bytes, fallback: 237 = default MTU). Each chunk consumes one credit. +2. **Timer-based sending.** Data is sent on a periodic timer matching the official Marklife app's approach. The timer interval is `packetDelayMs` (P15: 30ms, default: 30ms). On each tick, at most one packet is sent if credits are available. -3. **Credit grants.** The printer sends credit notifications on the CX characteristic. The protocol parses `[0x01, count]` as a credit grant. `FlowController.grantCredits(count)` adds them. +3. **Packet chunking.** Data is split into chunks of `packetSize` bytes (P15: 95 bytes, P12: 90 bytes, fallback: 237 = default MTU). Each chunk consumes one credit. -4. **Waiting.** When credits reach 0, `send()` polls every `timerIntervalMs` (default: 30ms) until a credit arrives. +4. **Credit grants.** The printer sends credit notifications on the CX characteristic. The protocol parses `[0x01, count]` as a credit grant. `FlowController.grantCredits(count)` adds them. -5. **Starvation recovery.** If no credit arrives within `starvationTimeoutMs` (default: 1000ms), the controller forces `credits = 1` and continues. This prevents deadlocks caused by lost BLE notifications. - -6. **Hard timeout.** If starvation recovery repeats for 10x the starvation timeout (10 seconds), a `FLOW_CONTROL_TIMEOUT` error is thrown. +5. **Starvation recovery.** If no credit arrives within `starvationTimeoutMs` (default: 1000ms), the controller unconditionally forces `credits = 1` and continues. This matches the official app's recovery logic and prevents deadlocks caused by lost BLE notifications. ### Sequence diagram diff --git a/packages/core/src/device/profiles/p12.ts b/packages/core/src/device/profiles/p12.ts index b6a626c..aadee4a 100644 --- a/packages/core/src/device/profiles/p12.ts +++ b/packages/core/src/device/profiles/p12.ts @@ -36,7 +36,6 @@ export const p12Profile: DeviceProfile = { }, packetSize: 90, flowControl: { - initialCredits: 4, packetDelayMs: 30, }, defaults: { density: 2, paperType: "gap" }, diff --git a/packages/core/src/device/profiles/p15.ts b/packages/core/src/device/profiles/p15.ts index b93fa28..9c56765 100644 --- a/packages/core/src/device/profiles/p15.ts +++ b/packages/core/src/device/profiles/p15.ts @@ -24,7 +24,6 @@ export const p15Profile: DeviceProfile = { }, packetSize: 95, flowControl: { - initialCredits: 4, packetDelayMs: 30, }, defaults: { density: 2, paperType: "gap" }, diff --git a/packages/core/src/device/types.ts b/packages/core/src/device/types.ts index f1c3e4f..941e39e 100644 --- a/packages/core/src/device/types.ts +++ b/packages/core/src/device/types.ts @@ -1,7 +1,5 @@ export interface FlowControlOptions { - initialCredits: number; starvationTimeoutMs: number; - timerIntervalMs: number; packetDelayMs: number; } diff --git a/packages/core/src/printer.ts b/packages/core/src/printer.ts index 5080663..69a5514 100644 --- a/packages/core/src/printer.ts +++ b/packages/core/src/printer.ts @@ -131,8 +131,6 @@ export class Printer { copies: options.copies, }; - this.flowController.reset(); - const commands = this.protocol.buildPrintSequence(image, mergedOptions); const totalBytes = commands.reduce((sum, cmd) => sum + cmd.data.length, 0); let bytesSent = 0; diff --git a/packages/core/src/transport/flow-control.ts b/packages/core/src/transport/flow-control.ts index 8698bea..05993cb 100644 --- a/packages/core/src/transport/flow-control.ts +++ b/packages/core/src/transport/flow-control.ts @@ -1,22 +1,17 @@ import type { BleCharacteristic } from "./types.js"; import type { FlowControlOptions } from "../device/types.js"; -import { ThermoprintError, ErrorCode } from "../errors.js"; import { debugLog } from "../debug-log.js"; const DEFAULT_OPTIONS: FlowControlOptions = { - initialCredits: 4, starvationTimeoutMs: 1000, - timerIntervalMs: 5, packetDelayMs: 0, }; export class FlowController { - private credits: number; + private credits: number = 0; private readonly options: FlowControlOptions; private lastCreditTime: number = Date.now(); private packetSize: number; - /** True once the printer grants at least one real credit during a send. */ - private hasRealCredits = false; constructor( private readonly tx: BleCharacteristic, @@ -25,27 +20,14 @@ export class FlowController { ) { this.packetSize = packetSize; this.options = { ...DEFAULT_OPTIONS, ...options }; - this.credits = this.options.initialCredits; } setPacketSize(size: number): void { this.packetSize = size; } - /** Reset credits to initial state before a new print job. */ - reset(): void { - this.credits = this.options.initialCredits; - // Preserve hasRealCredits — it reflects whether this device supports - // credit-based flow control, which is a connection-level property. - // Clearing it would let starvation recovery bypass flow control at the - // start of every print job. - this.lastCreditTime = Date.now(); - debugLog("FC", `reset, credits=${this.credits}`); - } - grantCredits(count: number): void { this.credits += count; - this.hasRealCredits = true; this.lastCreditTime = Date.now(); debugLog("FC", `+${count} credits, total=${this.credits}`); } @@ -53,9 +35,11 @@ export class FlowController { /** * Send data through the BLE characteristic with credit-based flow control. * - * Uses a timer-style approach: each packet waits for a credit (or a short - * starvation recovery) before sending, ensuring steady throughput even when - * BLE notifications are delayed or dropped. + * 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). */ async send( data: Uint8Array, @@ -64,69 +48,53 @@ export class FlowController { let offset = 0; debugLog("TX", `sending ${data.length}B in ${Math.ceil(data.length / this.packetSize)} packets, credits=${this.credits}`); - const { packetDelayMs } = this.options; - - while (offset < data.length) { - await this.waitForCredit(); - - if (packetDelayMs > 0 && offset > 0) { - await new Promise((r) => setTimeout(r, packetDelayMs)); - } - - 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--; - offset += chunkSize; - - onProgress?.(offset); - } - } - - private async waitForCredit(): Promise { - if (this.credits > 0) return; - - const { starvationTimeoutMs, timerIntervalMs } = this.options; + const { packetDelayMs, starvationTimeoutMs } = this.options; + const tickMs = packetDelayMs > 0 ? packetDelayMs : 30; return new Promise((resolve, reject) => { - const startTime = Date.now(); - - const check = () => { - if (this.credits > 0) { - resolve(); - return; - } - - // Starvation recovery: force 1 credit after timeout. - // Only used when the printer doesn't actively grant credits - // (common over Web Bluetooth). If the printer has been sending - // real credits via CX, respect its flow control — a pause means - // "buffer full", not "I don't do flow control". - if (!this.hasRealCredits && Date.now() - this.lastCreditTime >= starvationTimeoutMs) { - debugLog("FC", `starvation recovery after ${Date.now() - startTime}ms, forcing 1 credit`); - this.credits = 1; - this.lastCreditTime = Date.now(); - resolve(); - return; - } - - // Hard timeout: give up after 5 seconds - if (Date.now() - startTime >= 5000) { - reject( - new ThermoprintError( - ErrorCode.FLOW_CONTROL_TIMEOUT, - "Flow control timeout: no credits received", - ), - ); - return; + const tick = async () => { + try { + if (offset >= data.length) { + resolve(); + 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. + 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) + 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--; + offset += chunkSize; + + onProgress?.(offset); + + if (offset >= data.length) { + resolve(); + return; + } + } + + setTimeout(tick, tickMs); + } catch (err) { + reject(err); } - - setTimeout(check, timerIntervalMs); }; - setTimeout(check, timerIntervalMs); + // First tick fires immediately (credits are pre-loaded) + tick(); }); } diff --git a/packages/core/test/transport/flow-control.test.ts b/packages/core/test/transport/flow-control.test.ts index 266f7d5..e4f6001 100644 --- a/packages/core/test/transport/flow-control.test.ts +++ b/packages/core/test/transport/flow-control.test.ts @@ -17,7 +17,8 @@ function createMockTx(): BleCharacteristic & { written: Uint8Array[] } { describe("FlowController", () => { test("chunks data according to packet size", async () => { const tx = createMockTx(); - const fc = new FlowController(tx, 10, { initialCredits: 100 }); + const fc = new FlowController(tx, 10, { packetDelayMs: 1 }); + fc.grantCredits(100); const data = new Uint8Array(25); await fc.send(data); @@ -30,7 +31,8 @@ describe("FlowController", () => { test("uses write without response", async () => { const tx = createMockTx(); - const fc = new FlowController(tx, 100, { initialCredits: 4 }); + const fc = new FlowController(tx, 100, { packetDelayMs: 1 }); + fc.grantCredits(4); await fc.send(new Uint8Array(10)); expect(tx.write).toHaveBeenCalledWith(expect.any(Uint8Array), true); @@ -38,7 +40,8 @@ describe("FlowController", () => { test("decrements credits on send", async () => { const tx = createMockTx(); - const fc = new FlowController(tx, 100, { initialCredits: 4 }); + const fc = new FlowController(tx, 100, { packetDelayMs: 1 }); + fc.grantCredits(4); await fc.send(new Uint8Array(10)); expect(fc.availableCredits).toBe(3); @@ -46,15 +49,25 @@ describe("FlowController", () => { test("grantCredits increases available credits", async () => { const tx = createMockTx(); - const fc = new FlowController(tx, 100, { initialCredits: 2 }); + const fc = new FlowController(tx, 100); fc.grantCredits(3); + expect(fc.availableCredits).toBe(3); + + fc.grantCredits(2); expect(fc.availableCredits).toBe(5); }); + test("starts with zero credits", () => { + const tx = createMockTx(); + const fc = new FlowController(tx, 100); + expect(fc.availableCredits).toBe(0); + }); + test("reports progress during send", async () => { const tx = createMockTx(); - const fc = new FlowController(tx, 10, { initialCredits: 100 }); + const fc = new FlowController(tx, 10, { packetDelayMs: 1 }); + fc.grantCredits(100); const progress: number[] = []; await fc.send(new Uint8Array(25), (sent) => progress.push(sent)); @@ -65,12 +78,12 @@ describe("FlowController", () => { test("starvation recovery forces 1 credit", async () => { const tx = createMockTx(); const fc = new FlowController(tx, 100, { - initialCredits: 1, starvationTimeoutMs: 50, - timerIntervalMs: 10, + packetDelayMs: 10, }); - // Send first packet to exhaust credits + // Grant 1 credit, send to exhaust it + fc.grantCredits(1); await fc.send(new Uint8Array(10)); expect(fc.availableCredits).toBe(0);