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
5 changes: 2 additions & 3 deletions docs/adding-a-printer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<FlowControlOptions>` | Override `initialCredits`, `starvationTimeoutMs`, `timerIntervalMs` |
| `flowControl` | `Partial<FlowControlOptions>` | 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 |
Expand Down
12 changes: 5 additions & 7 deletions docs/transport.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 0 additions & 1 deletion packages/core/src/device/profiles/p12.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ export const p12Profile: DeviceProfile = {
},
packetSize: 90,
flowControl: {
initialCredits: 4,
packetDelayMs: 30,
},
defaults: { density: 2, paperType: "gap" },
Expand Down
1 change: 0 additions & 1 deletion packages/core/src/device/profiles/p15.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ export const p15Profile: DeviceProfile = {
},
packetSize: 95,
flowControl: {
initialCredits: 4,
packetDelayMs: 30,
},
defaults: { density: 2, paperType: "gap" },
Expand Down
2 changes: 0 additions & 2 deletions packages/core/src/device/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
export interface FlowControlOptions {
initialCredits: number;
starvationTimeoutMs: number;
timerIntervalMs: number;
packetDelayMs: number;
}

Expand Down
2 changes: 0 additions & 2 deletions packages/core/src/printer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
128 changes: 48 additions & 80 deletions packages/core/src/transport/flow-control.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -25,37 +20,26 @@ 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}`);
}

/**
* 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,
Expand All @@ -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<void> {
if (this.credits > 0) return;

const { starvationTimeoutMs, timerIntervalMs } = this.options;
const { packetDelayMs, starvationTimeoutMs } = this.options;
const tickMs = packetDelayMs > 0 ? packetDelayMs : 30;

return new Promise<void>((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();
});
}

Expand Down
29 changes: 21 additions & 8 deletions packages/core/test/transport/flow-control.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -30,31 +31,43 @@ 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);
});

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);
});

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));
Expand All @@ -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);

Expand Down
Loading