Skip to content

Commit 2dbc9ca

Browse files
authored
Merge pull request #14 from tomLadder/fix/p15-clone-packet-size-and-flow-control
Fix P15 clone printing and match official app flow control
2 parents 7aa6c45 + 2363522 commit 2dbc9ca

8 files changed

Lines changed: 76 additions & 104 deletions

File tree

docs/adding-a-printer.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,8 @@ export const p20Profile: DeviceProfile = {
2121
},
2222
packetSize: 95, // BLE write chunk size in bytes
2323
flowControl: {
24-
initialCredits: 4,
2524
starvationTimeoutMs: 1000,
26-
timerIntervalMs: 30,
25+
packetDelayMs: 30,
2726
},
2827
defaults: { density: 2, paperType: "gap" }, // "gap" for label paper, "continuous" for receipt
2928
namePrefixes: ["P20", "P20S"], // BLE advertised name prefixes
@@ -53,7 +52,7 @@ That's it. `discover()` will now match peripherals whose name starts with "P20"
5352
| `characteristics.rx` | `string` | Notify characteristic for responses |
5453
| `characteristics.cx` | `string?` | Notify characteristic for flow control credits (optional) |
5554
| `packetSize` | `number?` | Max bytes per BLE write (default: 237) |
56-
| `flowControl` | `Partial<FlowControlOptions>` | Override `initialCredits`, `starvationTimeoutMs`, `timerIntervalMs` |
55+
| `flowControl` | `Partial<FlowControlOptions>` | Override `starvationTimeoutMs`, `packetDelayMs` |
5756
| `defaults.density` | `number` | Default print density (0-3) |
5857
| `defaults.paperType` | `"gap" \| "continuous"` | Default paper type |
5958
| `namePrefixes` | `string[]` | BLE name prefixes to match during discovery |

docs/transport.md

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -220,17 +220,15 @@ Thermoprint uses **credit-based flow control** to avoid overwhelming the printer
220220

221221
### How it works
222222

223-
1. **Initial credits.** The `FlowController` starts with `initialCredits` (default: 4). Each credit allows sending one packet.
223+
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.
224224

225-
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.
225+
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.
226226

227-
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.
227+
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.
228228

229-
4. **Waiting.** When credits reach 0, `send()` polls every `timerIntervalMs` (default: 30ms) until a credit arrives.
229+
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.
230230

231-
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.
232-
233-
6. **Hard timeout.** If starvation recovery repeats for 10x the starvation timeout (10 seconds), a `FLOW_CONTROL_TIMEOUT` error is thrown.
231+
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.
234232

235233
### Sequence diagram
236234

packages/core/src/device/profiles/p12.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ export const p12Profile: DeviceProfile = {
3636
},
3737
packetSize: 90,
3838
flowControl: {
39-
initialCredits: 4,
4039
packetDelayMs: 30,
4140
},
4241
defaults: { density: 2, paperType: "gap" },

packages/core/src/device/profiles/p15.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ export const p15Profile: DeviceProfile = {
2424
},
2525
packetSize: 95,
2626
flowControl: {
27-
initialCredits: 4,
2827
packetDelayMs: 30,
2928
},
3029
defaults: { density: 2, paperType: "gap" },

packages/core/src/device/types.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
export interface FlowControlOptions {
2-
initialCredits: number;
32
starvationTimeoutMs: number;
4-
timerIntervalMs: number;
53
packetDelayMs: number;
64
}
75

packages/core/src/printer.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,8 +131,6 @@ export class Printer {
131131
copies: options.copies,
132132
};
133133

134-
this.flowController.reset();
135-
136134
const commands = this.protocol.buildPrintSequence(image, mergedOptions);
137135
const totalBytes = commands.reduce((sum, cmd) => sum + cmd.data.length, 0);
138136
let bytesSent = 0;

packages/core/src/transport/flow-control.ts

Lines changed: 48 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,17 @@
11
import type { BleCharacteristic } from "./types.js";
22
import type { FlowControlOptions } from "../device/types.js";
3-
import { ThermoprintError, ErrorCode } from "../errors.js";
43
import { debugLog } from "../debug-log.js";
54

65
const DEFAULT_OPTIONS: FlowControlOptions = {
7-
initialCredits: 4,
86
starvationTimeoutMs: 1000,
9-
timerIntervalMs: 5,
107
packetDelayMs: 0,
118
};
129

1310
export class FlowController {
14-
private credits: number;
11+
private credits: number = 0;
1512
private readonly options: FlowControlOptions;
1613
private lastCreditTime: number = Date.now();
1714
private packetSize: number;
18-
/** True once the printer grants at least one real credit during a send. */
19-
private hasRealCredits = false;
2015

2116
constructor(
2217
private readonly tx: BleCharacteristic,
@@ -25,37 +20,26 @@ export class FlowController {
2520
) {
2621
this.packetSize = packetSize;
2722
this.options = { ...DEFAULT_OPTIONS, ...options };
28-
this.credits = this.options.initialCredits;
2923
}
3024

3125
setPacketSize(size: number): void {
3226
this.packetSize = size;
3327
}
3428

35-
/** Reset credits to initial state before a new print job. */
36-
reset(): void {
37-
this.credits = this.options.initialCredits;
38-
// Preserve hasRealCredits — it reflects whether this device supports
39-
// credit-based flow control, which is a connection-level property.
40-
// Clearing it would let starvation recovery bypass flow control at the
41-
// start of every print job.
42-
this.lastCreditTime = Date.now();
43-
debugLog("FC", `reset, credits=${this.credits}`);
44-
}
45-
4629
grantCredits(count: number): void {
4730
this.credits += count;
48-
this.hasRealCredits = true;
4931
this.lastCreditTime = Date.now();
5032
debugLog("FC", `+${count} credits, total=${this.credits}`);
5133
}
5234

5335
/**
5436
* Send data through the BLE characteristic with credit-based flow control.
5537
*
56-
* Uses a timer-style approach: each packet waits for a credit (or a short
57-
* starvation recovery) before sending, ensuring steady throughput even when
58-
* BLE notifications are delayed or dropped.
38+
* Matches the official Marklife app's timer-based approach: a periodic timer
39+
* fires at a fixed interval (e.g. 30ms for P15). On each tick, at most one
40+
* packet is sent if credits are available. If credits have been exhausted for
41+
* longer than the starvation timeout, one credit is forced unconditionally
42+
* (matching the official app's recovery logic).
5943
*/
6044
async send(
6145
data: Uint8Array,
@@ -64,69 +48,53 @@ export class FlowController {
6448
let offset = 0;
6549
debugLog("TX", `sending ${data.length}B in ${Math.ceil(data.length / this.packetSize)} packets, credits=${this.credits}`);
6650

67-
const { packetDelayMs } = this.options;
68-
69-
while (offset < data.length) {
70-
await this.waitForCredit();
71-
72-
if (packetDelayMs > 0 && offset > 0) {
73-
await new Promise((r) => setTimeout(r, packetDelayMs));
74-
}
75-
76-
const remaining = data.length - offset;
77-
const chunkSize = Math.min(remaining, this.packetSize);
78-
const chunk = data.subarray(offset, offset + chunkSize);
79-
80-
await this.tx.write(chunk, true); // withoutResponse = true
81-
this.credits--;
82-
offset += chunkSize;
83-
84-
onProgress?.(offset);
85-
}
86-
}
87-
88-
private async waitForCredit(): Promise<void> {
89-
if (this.credits > 0) return;
90-
91-
const { starvationTimeoutMs, timerIntervalMs } = this.options;
51+
const { packetDelayMs, starvationTimeoutMs } = this.options;
52+
const tickMs = packetDelayMs > 0 ? packetDelayMs : 30;
9253

9354
return new Promise<void>((resolve, reject) => {
94-
const startTime = Date.now();
95-
96-
const check = () => {
97-
if (this.credits > 0) {
98-
resolve();
99-
return;
100-
}
101-
102-
// Starvation recovery: force 1 credit after timeout.
103-
// Only used when the printer doesn't actively grant credits
104-
// (common over Web Bluetooth). If the printer has been sending
105-
// real credits via CX, respect its flow control — a pause means
106-
// "buffer full", not "I don't do flow control".
107-
if (!this.hasRealCredits && Date.now() - this.lastCreditTime >= starvationTimeoutMs) {
108-
debugLog("FC", `starvation recovery after ${Date.now() - startTime}ms, forcing 1 credit`);
109-
this.credits = 1;
110-
this.lastCreditTime = Date.now();
111-
resolve();
112-
return;
113-
}
114-
115-
// Hard timeout: give up after 5 seconds
116-
if (Date.now() - startTime >= 5000) {
117-
reject(
118-
new ThermoprintError(
119-
ErrorCode.FLOW_CONTROL_TIMEOUT,
120-
"Flow control timeout: no credits received",
121-
),
122-
);
123-
return;
55+
const tick = async () => {
56+
try {
57+
if (offset >= data.length) {
58+
resolve();
59+
return;
60+
}
61+
62+
// Starvation recovery: force 1 credit unconditionally after timeout,
63+
// matching the official app. This keeps data flowing even when BLE
64+
// notifications are lost (Web Bluetooth) or the printer is slow to
65+
// grant credits.
66+
if (this.credits <= 0 && Date.now() - this.lastCreditTime >= starvationTimeoutMs) {
67+
debugLog("FC", `starvation recovery, forcing 1 credit`);
68+
this.credits = 1;
69+
this.lastCreditTime = Date.now();
70+
}
71+
72+
// Send at most one packet per tick (matching official app timer cadence)
73+
if (this.credits > 0) {
74+
const remaining = data.length - offset;
75+
const chunkSize = Math.min(remaining, this.packetSize);
76+
const chunk = data.subarray(offset, offset + chunkSize);
77+
78+
await this.tx.write(chunk, true); // withoutResponse = true
79+
this.credits--;
80+
offset += chunkSize;
81+
82+
onProgress?.(offset);
83+
84+
if (offset >= data.length) {
85+
resolve();
86+
return;
87+
}
88+
}
89+
90+
setTimeout(tick, tickMs);
91+
} catch (err) {
92+
reject(err);
12493
}
125-
126-
setTimeout(check, timerIntervalMs);
12794
};
12895

129-
setTimeout(check, timerIntervalMs);
96+
// First tick fires immediately (credits are pre-loaded)
97+
tick();
13098
});
13199
}
132100

packages/core/test/transport/flow-control.test.ts

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ function createMockTx(): BleCharacteristic & { written: Uint8Array[] } {
1717
describe("FlowController", () => {
1818
test("chunks data according to packet size", async () => {
1919
const tx = createMockTx();
20-
const fc = new FlowController(tx, 10, { initialCredits: 100 });
20+
const fc = new FlowController(tx, 10, { packetDelayMs: 1 });
21+
fc.grantCredits(100);
2122

2223
const data = new Uint8Array(25);
2324
await fc.send(data);
@@ -30,31 +31,43 @@ describe("FlowController", () => {
3031

3132
test("uses write without response", async () => {
3233
const tx = createMockTx();
33-
const fc = new FlowController(tx, 100, { initialCredits: 4 });
34+
const fc = new FlowController(tx, 100, { packetDelayMs: 1 });
35+
fc.grantCredits(4);
3436

3537
await fc.send(new Uint8Array(10));
3638
expect(tx.write).toHaveBeenCalledWith(expect.any(Uint8Array), true);
3739
});
3840

3941
test("decrements credits on send", async () => {
4042
const tx = createMockTx();
41-
const fc = new FlowController(tx, 100, { initialCredits: 4 });
43+
const fc = new FlowController(tx, 100, { packetDelayMs: 1 });
44+
fc.grantCredits(4);
4245

4346
await fc.send(new Uint8Array(10));
4447
expect(fc.availableCredits).toBe(3);
4548
});
4649

4750
test("grantCredits increases available credits", async () => {
4851
const tx = createMockTx();
49-
const fc = new FlowController(tx, 100, { initialCredits: 2 });
52+
const fc = new FlowController(tx, 100);
5053

5154
fc.grantCredits(3);
55+
expect(fc.availableCredits).toBe(3);
56+
57+
fc.grantCredits(2);
5258
expect(fc.availableCredits).toBe(5);
5359
});
5460

61+
test("starts with zero credits", () => {
62+
const tx = createMockTx();
63+
const fc = new FlowController(tx, 100);
64+
expect(fc.availableCredits).toBe(0);
65+
});
66+
5567
test("reports progress during send", async () => {
5668
const tx = createMockTx();
57-
const fc = new FlowController(tx, 10, { initialCredits: 100 });
69+
const fc = new FlowController(tx, 10, { packetDelayMs: 1 });
70+
fc.grantCredits(100);
5871

5972
const progress: number[] = [];
6073
await fc.send(new Uint8Array(25), (sent) => progress.push(sent));
@@ -65,12 +78,12 @@ describe("FlowController", () => {
6578
test("starvation recovery forces 1 credit", async () => {
6679
const tx = createMockTx();
6780
const fc = new FlowController(tx, 100, {
68-
initialCredits: 1,
6981
starvationTimeoutMs: 50,
70-
timerIntervalMs: 10,
82+
packetDelayMs: 10,
7183
});
7284

73-
// Send first packet to exhaust credits
85+
// Grant 1 credit, send to exhaust it
86+
fc.grantCredits(1);
7487
await fc.send(new Uint8Array(10));
7588
expect(fc.availableCredits).toBe(0);
7689

0 commit comments

Comments
 (0)