Skip to content

Commit b152ce8

Browse files
authored
Merge pull request #18 from tomLadder/feat/m60-x2-support
Add Ablemark M60 / Marklife X2 printer support
2 parents f29e455 + 48408bb commit b152ce8

9 files changed

Lines changed: 339 additions & 1 deletion

File tree

bun.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
{
22
"name": "thermoprint",
33
"private": true,
4-
"workspaces": ["packages/*"]
4+
"workspaces": [
5+
"packages/*"
6+
],
7+
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
58
}

packages/core/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,8 @@
1717
"devDependencies": {
1818
"typescript": "^5.7.0",
1919
"@types/bun": "latest"
20+
},
21+
"dependencies": {
22+
"fflate": "^0.8.2"
2023
}
2124
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { DeviceProfile, LabelSizePreset } from "../types.js";
2+
3+
const gapSizes: LabelSizePreset[] = [
4+
{ widthMm: 20, heightMm: 10 },
5+
{ widthMm: 30, heightMm: 15 },
6+
{ widthMm: 40, heightMm: 12 },
7+
{ widthMm: 40, heightMm: 20 },
8+
{ widthMm: 40, heightMm: 30 },
9+
{ widthMm: 50, heightMm: 30 },
10+
{ widthMm: 50, heightMm: 40 },
11+
];
12+
13+
const continuousSizes: LabelSizePreset[] = [
14+
{ widthMm: 30, heightMm: 15 },
15+
{ widthMm: 40, heightMm: 20 },
16+
{ widthMm: 40, heightMm: 30 },
17+
{ widthMm: 50, heightMm: 30 },
18+
{ widthMm: 50, heightMm: 40 },
19+
];
20+
21+
export const m60Profile: DeviceProfile = {
22+
modelId: "m60",
23+
protocolId: "x2",
24+
serviceUuid: "0000ff00-0000-1000-8000-00805f9b34fb",
25+
characteristics: {
26+
tx: "0000ff02-0000-1000-8000-00805f9b34fb",
27+
rx: "0000ff01-0000-1000-8000-00805f9b34fb",
28+
cx: "0000ff03-0000-1000-8000-00805f9b34fb",
29+
},
30+
flowControl: {
31+
packetDelayMs: 1,
32+
},
33+
defaults: { density: 2, paperType: "gap" },
34+
namePrefixes: ["M60", "X2"],
35+
labelConfig: {
36+
supportedPaperTypes: ["gap", "continuous"],
37+
defaultPaperType: "gap",
38+
gapSizes,
39+
continuousSizes,
40+
defaultSize: { widthMm: 50, heightMm: 30 },
41+
},
42+
};

packages/core/src/device/registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { DeviceProfile } from "./types.js";
22
import { p15Profile } from "./profiles/p15.js";
33
import { p12Profile } from "./profiles/p12.js";
4+
import { m60Profile } from "./profiles/m60.js";
45

56
const devices: DeviceProfile[] = [];
67

@@ -30,3 +31,4 @@ export function getRegisteredDevices(): DeviceProfile[] {
3031
// Register built-in devices
3132
registerDevice(p15Profile);
3233
registerDevice(p12Profile);
34+
registerDevice(m60Profile);

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export type {
2828
} from "./protocol/types.js";
2929
export { registerProtocol, getProtocol } from "./protocol/registry.js";
3030
export { L11Protocol } from "./protocol/l11/protocol.js";
31+
export { X2Protocol } from "./protocol/x2/protocol.js";
3132

3233
// Device types & registry (for adding new printer models)
3334
export type {

packages/core/src/protocol/registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { PrinterProtocol } from "./types.js";
22
import { L11Protocol } from "./l11/protocol.js";
3+
import { X2Protocol } from "./x2/protocol.js";
34
import { ThermoprintError, ErrorCode } from "../errors.js";
45

56
type ProtocolFactory = () => PrinterProtocol;
@@ -27,3 +28,4 @@ export function getRegisteredProtocolIds(): string[] {
2728

2829
// Register built-in protocols
2930
registerProtocol("l11", () => new L11Protocol());
31+
registerProtocol("x2", () => new X2Protocol());
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { zlibSync } from "fflate";
2+
import type { ImageBitmap1bpp, PrintCommand } from "../types.js";
3+
4+
/** 6 zero bytes to wake the printer */
5+
export function wakeup(): PrintCommand {
6+
return { label: "wakeup", data: new Uint8Array(6) };
7+
}
8+
9+
/** Start print job: 1F C0 01 00 */
10+
export function enable(): PrintCommand {
11+
return { label: "enable", data: Uint8Array.from([0x1f, 0xc0, 0x01, 0x00]) };
12+
}
13+
14+
/** End print job: 1F C0 01 01 */
15+
export function stop(): PrintCommand {
16+
return { label: "stop", data: Uint8Array.from([0x1f, 0xc0, 0x01, 0x01]) };
17+
}
18+
19+
/**
20+
* Set density: 1F 70 02 <value>
21+
* Density mapping: 1→3, 2→8, 3→14
22+
*/
23+
export function setDensity(density: number): PrintCommand {
24+
const DENSITY_MAP: Record<number, number> = { 1: 3, 2: 8, 3: 14 };
25+
const value = DENSITY_MAP[density] ?? 8;
26+
return {
27+
label: "set-density",
28+
data: Uint8Array.from([0x1f, 0x70, 0x02, value & 0xff]),
29+
};
30+
}
31+
32+
/** Set paper type to gap: 1F 80 02 20 */
33+
export function setPaperTypeGap(): PrintCommand {
34+
return {
35+
label: "set-paper-type",
36+
data: Uint8Array.from([0x1f, 0x80, 0x02, 0x20]),
37+
};
38+
}
39+
40+
/** Feed n dots: 1B 4A <lo> <hi> 00 */
41+
export function feedDots(dots: number): PrintCommand {
42+
return {
43+
label: "feed-dots",
44+
data: Uint8Array.from([0x1b, 0x4a, dots & 0xff, (dots >> 8) & 0xff, 0x00]),
45+
};
46+
}
47+
48+
/** Adjust position auto: 1F 11 <param> */
49+
export function adjustPositionAuto(param: number): PrintCommand {
50+
return {
51+
label: "adjust-position",
52+
data: Uint8Array.from([0x1f, 0x11, param & 0xff]),
53+
};
54+
}
55+
56+
/** Printer location: 1F 12 <x> <y> */
57+
export function printerLocation(x: number, y: number): PrintCommand {
58+
return {
59+
label: "printer-location",
60+
data: Uint8Array.from([0x1f, 0x12, x & 0xff, y & 0xff]),
61+
};
62+
}
63+
64+
/**
65+
* Build a compressed bitmap command: 1F 10 <wh> <wl> <hh> <hl> <len4> + zlib data
66+
* Compression: standard zlib compress() with default parameters (level 6).
67+
*/
68+
export function printBitmap(image: ImageBitmap1bpp): PrintCommand {
69+
const { data: pixels, bytesPerRow, height } = image;
70+
const compressed = zlibSync(pixels, { level: 6 });
71+
72+
const header = Uint8Array.from([
73+
0x1f,
74+
0x10,
75+
(bytesPerRow >> 8) & 0xff,
76+
bytesPerRow & 0xff,
77+
(height >> 8) & 0xff,
78+
height & 0xff,
79+
(compressed.length >> 24) & 0xff,
80+
(compressed.length >> 16) & 0xff,
81+
(compressed.length >> 8) & 0xff,
82+
compressed.length & 0xff,
83+
]);
84+
85+
const command = new Uint8Array(header.length + compressed.length);
86+
command.set(header, 0);
87+
command.set(compressed, header.length);
88+
89+
return { label: "print-bitmap", data: command, bulk: true };
90+
}
91+
92+
/** Query battery level: 10 FF 50 F1 */
93+
export function getBattery(): PrintCommand {
94+
return {
95+
label: "get-battery",
96+
data: Uint8Array.from([0x10, 0xff, 0x50, 0xf1]),
97+
};
98+
}
99+
100+
/** Query printer status: 10 FF 40 */
101+
export function getStatus(): PrintCommand {
102+
return { label: "get-status", data: Uint8Array.from([0x10, 0xff, 0x40]) };
103+
}
104+
105+
/** Query model string: 10 FF 20 F0 */
106+
export function getModel(): PrintCommand {
107+
return {
108+
label: "get-model",
109+
data: Uint8Array.from([0x10, 0xff, 0x20, 0xf0]),
110+
};
111+
}
112+
113+
/** Query firmware version: 10 FF 20 F1 */
114+
export function getFirmware(): PrintCommand {
115+
return {
116+
label: "get-firmware",
117+
data: Uint8Array.from([0x10, 0xff, 0x20, 0xf1]),
118+
};
119+
}
120+
121+
/** Query serial number: 10 FF 20 F2 */
122+
export function getSerial(): PrintCommand {
123+
return {
124+
label: "get-serial",
125+
data: Uint8Array.from([0x10, 0xff, 0x20, 0xf2]),
126+
};
127+
}
128+
129+
/** Query Bluetooth MAC address: 10 FF 20 F3 */
130+
export function getMac(): PrintCommand {
131+
return {
132+
label: "get-mac",
133+
data: Uint8Array.from([0x10, 0xff, 0x20, 0xf3]),
134+
};
135+
}
136+
137+
/** Query BT module version: 10 FF 30 10 */
138+
export function getBtVersion(): PrintCommand {
139+
return {
140+
label: "get-bt-version",
141+
data: Uint8Array.from([0x10, 0xff, 0x30, 0x10]),
142+
};
143+
}
144+
145+
/** Query BT device name: 10 FF 30 11 */
146+
export function getBtName(): PrintCommand {
147+
return {
148+
label: "get-bt-name",
149+
data: Uint8Array.from([0x10, 0xff, 0x30, 0x11]),
150+
};
151+
}
152+
153+
/** Query print speed: 1F 60 00 */
154+
export function getSpeed(): PrintCommand {
155+
return {
156+
label: "get-speed",
157+
data: Uint8Array.from([0x1f, 0x60, 0x00]),
158+
};
159+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import type {
2+
ImageBitmap1bpp,
3+
PrintCommand,
4+
PrinterProtocol,
5+
PrinterResponse,
6+
PrintSequenceOptions,
7+
} from "../types.js";
8+
import * as cmd from "./commands.js";
9+
10+
const STATUS_CODES: Record<number, string> = {
11+
0x01: "out_of_paper",
12+
0x02: "cover_open",
13+
0x03: "overheating",
14+
0x04: "low_battery",
15+
0x05: "cover_closed",
16+
};
17+
18+
export class X2Protocol implements PrinterProtocol {
19+
readonly id = "x2";
20+
21+
buildPrintSequence(
22+
image: ImageBitmap1bpp,
23+
options: PrintSequenceOptions = {},
24+
): PrintCommand[] {
25+
const { density, paperType = "gap" } = options;
26+
const commands: PrintCommand[] = [];
27+
28+
if (paperType === "gap") {
29+
commands.push(cmd.setPaperTypeGap());
30+
}
31+
32+
if (density !== undefined) {
33+
commands.push(cmd.setDensity(density));
34+
}
35+
36+
commands.push(cmd.wakeup());
37+
commands.push(cmd.enable());
38+
39+
if (paperType === "gap") {
40+
commands.push(cmd.adjustPositionAuto(0x51));
41+
} else {
42+
commands.push(cmd.feedDots(100));
43+
}
44+
45+
commands.push(cmd.printBitmap(image));
46+
47+
if (paperType === "gap") {
48+
commands.push(cmd.printerLocation(0x20, 0x00));
49+
} else {
50+
commands.push(cmd.printerLocation(0x00, 0x00));
51+
}
52+
53+
commands.push(cmd.stop());
54+
55+
if (paperType === "gap") {
56+
commands.push(cmd.adjustPositionAuto(0x50));
57+
} else {
58+
commands.push(cmd.adjustPositionAuto(0x00));
59+
}
60+
61+
return commands;
62+
}
63+
64+
buildWakeup(): PrintCommand[] {
65+
return [cmd.wakeup()];
66+
}
67+
68+
buildStatusQuery(): PrintCommand {
69+
return cmd.getStatus();
70+
}
71+
72+
buildBatteryQuery(): PrintCommand {
73+
return cmd.getBattery();
74+
}
75+
76+
buildModelQuery(): PrintCommand {
77+
return cmd.getModel();
78+
}
79+
80+
buildInfoQuery(type: "firmware" | "serial" | "mac" | "bt-version" | "bt-name" | "speed"): PrintCommand {
81+
switch (type) {
82+
case "firmware": return cmd.getFirmware();
83+
case "serial": return cmd.getSerial();
84+
case "mac": return cmd.getMac();
85+
case "bt-version": return cmd.getBtVersion();
86+
case "bt-name": return cmd.getBtName();
87+
case "speed": return cmd.getSpeed();
88+
}
89+
}
90+
91+
parseResponse(data: Uint8Array): PrinterResponse | null {
92+
if (data.length < 1) return null;
93+
94+
const first = data[0];
95+
96+
if (first === 0xaa || first === 0x4f || first === 0x4b) {
97+
return { type: "success", raw: data };
98+
}
99+
100+
if (data.length < 2) return null;
101+
const second = data[1];
102+
103+
if (first === 0x01) {
104+
return { type: "credit", raw: data, value: second };
105+
}
106+
107+
if (first === 0x02 && data.length >= 3) {
108+
const mtu = (data[2] << 8) | data[1];
109+
return { type: "mtu", raw: data, value: mtu };
110+
}
111+
112+
if (first === 0xff) {
113+
const status = STATUS_CODES[second];
114+
return {
115+
type: "status",
116+
raw: data,
117+
value: status ?? `unknown_${second.toString(16)}`,
118+
};
119+
}
120+
121+
return null;
122+
}
123+
}

0 commit comments

Comments
 (0)