node-thermal-printer-js 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,108 @@
1
+ # node-thermal-printer-js
2
+
3
+ Public npm package for sending ESC/POS print jobs to a PSF588 printer over BLE or classic Bluetooth COM port.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install node-thermal-printer-js
9
+ ```
10
+
11
+ ## Import
12
+
13
+ ```js
14
+ import { printData } from "node-thermal-printer-js";
15
+
16
+ await printData("Hello from npm!");
17
+ ```
18
+
19
+ ## Local Development
20
+
21
+ This project can print ESC/POS data to PSF588 using either:
22
+ - BLE via Python bridge (`ble_print.py` + `bleak`)
23
+ - Classic Bluetooth Serial (COM port) via `serialport`
24
+
25
+ ## Install Dependencies
26
+
27
+ ```bash
28
+ npm install
29
+ py -3.11 -m pip install -r requirements.txt
30
+ ```
31
+
32
+ ## Test Print
33
+
34
+ ### BLE (recommended when no COM port exists)
35
+
36
+ Default PSF588 setup (auto-detects printer and uses correct characteristic):
37
+
38
+ ```bash
39
+ node test-print.js ble
40
+ ```
41
+
42
+ Or with explicit order ID:
43
+
44
+ ```bash
45
+ node test-print.js ble ORDER123
46
+ ```
47
+
48
+ ### BLE with custom device/UUID
49
+
50
+ ```bash
51
+ node test-print.js ble ORDER123 PSF588 "AA:BB:CC:DD:EE:FF" "49535343-8841-43f4-a8d4-ecbe34729bb3"
52
+ ```
53
+
54
+ Args order for BLE:
55
+ 1. `ble`
56
+ 2. `orderId` (optional)
57
+ 3. `bleName` (optional, default: `PSF588`)
58
+ 4. `bleAddress` (optional, auto-detected if not provided)
59
+ 5. `charUUID` (optional, default: `49535343-8841-43f4-a8d4-ecbe34729bb3` for PSF588)
60
+
61
+ ### Scan for BLE devices
62
+
63
+ ```bash
64
+ node test-print.js scan
65
+ node test-print.js scan PSF588
66
+ ```
67
+
68
+ Output shows all available characteristics and their write permissions.
69
+
70
+ ### COM mode
71
+
72
+ ```bash
73
+ node test-print.js com ORDER123 COM5 9600
74
+ ```
75
+
76
+ ## Environment Variables
77
+
78
+ - `PRINTER_TRANSPORT=ble|com` (default: `ble`)
79
+ - `PRINTER_COM_PORT=COM5` (default: `COM5` for COM mode)
80
+ - `PRINTER_BLE_NAME=PSF588` (default: `PSF588`)
81
+ - `PRINTER_BLE_ADDRESS=AA:BB:CC:DD:EE:FF` (optional, auto-detected)
82
+ - `PRINTER_BLE_CHAR_UUID=49535343-8841-43f4-a8d4-ecbe34729bb3` (default for PSF588)
83
+ - `PRINTER_BLE_CONNECT_TIMEOUT=15` (seconds)
84
+ - `PRINTER_BLE_SCAN_TIMEOUT=10` (seconds)
85
+ - `PRINTER_BLE_PAIR=1` (set to enable OS pairing before connect)
86
+ - `PRINTER_PYTHON_CMD=py` (Python launcher)
87
+
88
+ ## Publish Checklist
89
+
90
+ Before publishing, make sure the package name is unique on npm and then run:
91
+
92
+ ```bash
93
+ npm pack --dry-run
94
+ npm publish --access public
95
+ ```
96
+
97
+ ## PSF588 Printer Info
98
+
99
+ - **Characteristic for printing:** `49535343-8841-43f4-a8d4-ecbe34729bb3`
100
+ - **Properties:** write-without-response, write
101
+ - **Data format:** ESC/POS (raw binary)
102
+
103
+ ## Troubleshooting
104
+
105
+ - **"Access Denied" on write:** Characteristic doesn't support writes. Use `node test-print.js scan PSF588` to find a writable one.
106
+ - **Connection timeout:** Keep printer in pairing mode, disconnect from other devices, ensure Bluetooth is enabled on Windows.
107
+ - **Python not found:** Install Python 3.11+ and ensure it's on PATH, or set `PRINTER_PYTHON_CMD=py -3.11`.
108
+
package/app.js ADDED
@@ -0,0 +1,197 @@
1
+ import { spawn } from "node:child_process";
2
+ import { fileURLToPath } from "node:url";
3
+ import path from "node:path";
4
+
5
+ const getEscPosPayload = (data) =>
6
+ Buffer.concat([
7
+ Buffer.from([0x1b, 0x40, 0x0a]),
8
+ Buffer.from(`${data}\n\n\n\n\n`, "utf-8"),
9
+ ]);
10
+
11
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
12
+ const bleScriptPath = path.join(scriptDir, "ble_print.py");
13
+
14
+ const runPythonProcess = ({ cmd, cmdArgs, scriptArgs }) =>
15
+ new Promise((resolve, reject) => {
16
+ const child = spawn(cmd, [...cmdArgs, ...scriptArgs], {
17
+ stdio: ["ignore", "pipe", "pipe"],
18
+ });
19
+
20
+ let stdout = "";
21
+ let stderr = "";
22
+
23
+ child.stdout.on("data", (chunk) => {
24
+ stdout += chunk.toString();
25
+ });
26
+
27
+ child.stderr.on("data", (chunk) => {
28
+ stderr += chunk.toString();
29
+ });
30
+
31
+ child.on("error", (err) => {
32
+ reject(new Error(`Launch error for ${cmd}: ${err.message}`));
33
+ });
34
+
35
+ child.on("close", (code) => {
36
+ if (code === 0) {
37
+ resolve({
38
+ ok: true,
39
+ stdout: stdout.trim(),
40
+ stderr: stderr.trim(),
41
+ code,
42
+ cmd,
43
+ cmdArgs,
44
+ });
45
+ return;
46
+ }
47
+
48
+ resolve({
49
+ ok: false,
50
+ stdout: stdout.trim(),
51
+ stderr: stderr.trim(),
52
+ code,
53
+ cmd,
54
+ cmdArgs,
55
+ });
56
+ });
57
+ });
58
+
59
+ const printViaComPort = async (order, options = {}) => {
60
+ const portPath = options.portPath || process.env.PRINTER_COM_PORT || "COM5";
61
+ const baudRate = options.baudRate || 9600;
62
+
63
+ // Dynamically import `serialport` only when COM transport is requested.
64
+ let SerialPortModule;
65
+ try {
66
+ SerialPortModule = await import("serialport");
67
+ } catch (e) {
68
+ return Promise.reject(
69
+ new Error(`serialport module not available: ${e.message}`),
70
+ );
71
+ }
72
+
73
+ const SerialPortCtor =
74
+ SerialPortModule.default || SerialPortModule.SerialPort || SerialPortModule;
75
+
76
+ const port = new SerialPortCtor({
77
+ path: portPath,
78
+ baudRate,
79
+ autoOpen: false,
80
+ });
81
+
82
+ return new Promise((resolve, reject) => {
83
+ port.open((err) => {
84
+ if (err)
85
+ return reject(new Error(`Failed to open ${portPath}: ${err.message}`));
86
+
87
+ const payload = getEscPosPayload(order);
88
+
89
+ port.write(payload, (writeErr) => {
90
+ if (writeErr) {
91
+ port.close(() => reject(writeErr));
92
+ return;
93
+ }
94
+
95
+ port.drain((drainErr) => {
96
+ port.close(() => {
97
+ if (drainErr) return reject(drainErr);
98
+ resolve();
99
+ });
100
+ });
101
+ });
102
+ });
103
+ });
104
+ };
105
+
106
+ const printViaBleBridge = (order, options = {}) => {
107
+ const payload = getEscPosPayload(order).toString("base64");
108
+ const args = [
109
+ bleScriptPath,
110
+ "--data-b64",
111
+ payload,
112
+ "--name",
113
+ options.bleName || process.env.PRINTER_BLE_NAME || "PSF588",
114
+ ];
115
+
116
+ if (options.bleAddress || process.env.PRINTER_BLE_ADDRESS) {
117
+ args.push(
118
+ "--address",
119
+ options.bleAddress || process.env.PRINTER_BLE_ADDRESS,
120
+ );
121
+ }
122
+
123
+ if (options.charUUID || process.env.PRINTER_BLE_CHAR_UUID) {
124
+ args.push(
125
+ "--char-uuid",
126
+ options.charUUID || process.env.PRINTER_BLE_CHAR_UUID,
127
+ );
128
+ }
129
+
130
+ if (options.connectTimeout || process.env.PRINTER_BLE_CONNECT_TIMEOUT) {
131
+ args.push(
132
+ "--connect-timeout",
133
+ String(options.connectTimeout || process.env.PRINTER_BLE_CONNECT_TIMEOUT),
134
+ );
135
+ }
136
+
137
+ if (options.scanTimeout || process.env.PRINTER_BLE_SCAN_TIMEOUT) {
138
+ args.push(
139
+ "--scan-timeout",
140
+ String(options.scanTimeout || process.env.PRINTER_BLE_SCAN_TIMEOUT),
141
+ );
142
+ }
143
+
144
+ if (options.pair || process.env.PRINTER_BLE_PAIR === "1") {
145
+ args.push("--pair");
146
+ }
147
+
148
+ const envCmd = options.pythonCmd || process.env.PRINTER_PYTHON_CMD;
149
+ const candidates = envCmd
150
+ ? [{ cmd: envCmd, cmdArgs: [] }]
151
+ : [
152
+ { cmd: "py", cmdArgs: ["-3.11"] },
153
+ { cmd: "py", cmdArgs: ["-3"] },
154
+ { cmd: "python", cmdArgs: [] },
155
+ ];
156
+
157
+ return new Promise(async (resolve, reject) => {
158
+ const failures = [];
159
+
160
+ for (const candidate of candidates) {
161
+ // Try multiple launchers because Windows py default can point to a missing runtime.
162
+ const result = await runPythonProcess({
163
+ cmd: candidate.cmd,
164
+ cmdArgs: candidate.cmdArgs,
165
+ scriptArgs: args,
166
+ });
167
+
168
+ if (result.ok) {
169
+ resolve(result.stdout);
170
+ return;
171
+ }
172
+
173
+ failures.push(
174
+ `${result.cmd} ${result.cmdArgs.join(" ")} -> code ${result.code}: ${result.stderr || result.stdout || "no output"}`,
175
+ );
176
+ }
177
+
178
+ reject(
179
+ new Error(
180
+ `BLE bridge failed for all Python launchers. ${failures.join(" | ")}`,
181
+ ),
182
+ );
183
+ });
184
+ };
185
+
186
+ // Print to a paired Bluetooth printer exposed as a Windows COM (RFCOMM) port.
187
+ // Preferable on Windows: pair the PSF588 printer in OS Bluetooth settings
188
+ // and note the outgoing COM port (e.g. COM5). Then call `printToPSF588(order, { portPath: 'COM5' })`.
189
+ export const printData = (order, options = {}) => {
190
+ const transport = options.transport || process.env.PRINTER_TRANSPORT || "ble";
191
+
192
+ if (transport === "ble") {
193
+ return printViaBleBridge(order, options);
194
+ }
195
+
196
+ return printViaComPort(order, options);
197
+ };
package/ble_print.py ADDED
@@ -0,0 +1,147 @@
1
+ import argparse
2
+ import asyncio
3
+ import base64
4
+ import sys
5
+ import traceback
6
+
7
+ from bleak import BleakClient, BleakScanner
8
+
9
+
10
+ def parse_args():
11
+ parser = argparse.ArgumentParser(description="Send ESC/POS bytes to a BLE printer")
12
+ parser.add_argument("--name", default="PSF588", help="BLE device name contains filter")
13
+ parser.add_argument("--address", default=None, help="BLE MAC/address to connect directly")
14
+ parser.add_argument(
15
+ "--char-uuid",
16
+ default="49535343-8841-43f4-a8d4-ecbe34729bb3",
17
+ help="Writable characteristic UUID (default works for PSF588)",
18
+ )
19
+ parser.add_argument("--data-b64", required=True, help="Base64 payload bytes")
20
+ parser.add_argument("--scan-timeout", type=float, default=10.0, help="Scan timeout seconds")
21
+ parser.add_argument("--connect-timeout", type=float, default=15.0, help="Connect timeout seconds")
22
+ parser.add_argument("--chunk-size", type=int, default=180, help="Write chunk size")
23
+ parser.add_argument("--delay-ms", type=int, default=20, help="Delay between chunks in ms")
24
+ parser.add_argument("--pair", action="store_true", help="Request OS pairing before connect")
25
+ parser.add_argument("--connect-retries", type=int, default=2, help="Connect retry attempts")
26
+ return parser.parse_args()
27
+
28
+
29
+ async def find_device(name_filter: str, timeout: float):
30
+ devices = await BleakScanner.discover(timeout=timeout)
31
+ name_filter = (name_filter or "").lower()
32
+
33
+ for device in devices:
34
+ name = (device.name or "").lower()
35
+ if name_filter and name_filter in name:
36
+ return device, device.address, device.name
37
+
38
+ return None, None, None
39
+
40
+
41
+ def pick_writable_characteristic(services):
42
+ # Prefer write-without-response characteristics (more reliable for bulk data)
43
+ candidates = []
44
+
45
+ for service in services:
46
+ for char in service.characteristics:
47
+ props = set(char.properties)
48
+ if "write-without-response" in props:
49
+ candidates.append((char.uuid, False))
50
+ elif "write" in props:
51
+ candidates.append((char.uuid, True))
52
+
53
+ # Return first write-without-response, then first write
54
+ for uuid, response_required in candidates:
55
+ if not response_required:
56
+ print(f"[DEBUG] Selected write-without-response characteristic: {uuid}")
57
+ return uuid, response_required
58
+
59
+ for uuid, response_required in candidates:
60
+ print(f"[DEBUG] Selected write characteristic (requires response): {uuid}")
61
+ return uuid, response_required
62
+
63
+ print("[DEBUG] No writable characteristics found")
64
+ return None, None
65
+
66
+
67
+ async def run_print(args):
68
+ payload = base64.b64decode(args.data_b64)
69
+
70
+ address = args.address
71
+ device = None
72
+ device_name = args.name
73
+
74
+ if not address:
75
+ device, address, found_name = await find_device(device_name, args.scan_timeout)
76
+ if not address:
77
+ raise RuntimeError(
78
+ f"No BLE device found for name filter '{device_name}'. "
79
+ "Try setting --address explicitly."
80
+ )
81
+ print(f"Found device: {found_name} ({address})")
82
+
83
+ target = device if device is not None else address
84
+
85
+ last_error = None
86
+ for attempt in range(1, max(1, args.connect_retries) + 1):
87
+ try:
88
+ async with BleakClient(
89
+ target,
90
+ timeout=args.connect_timeout,
91
+ pair=args.pair,
92
+ winrt={"use_cached_services": False},
93
+ ) as client:
94
+ if not client.is_connected:
95
+ raise RuntimeError(f"Failed to connect to {address}")
96
+
97
+ if hasattr(client, "get_services"):
98
+ services = await client.get_services()
99
+ else:
100
+ services = client.services
101
+
102
+ char_uuid = args.char_uuid
103
+ response_required = False
104
+
105
+ if not char_uuid:
106
+ # Re-scan services fresh and pick writable characteristic
107
+ char_uuid, response_required = pick_writable_characteristic(services)
108
+ if not char_uuid:
109
+ raise RuntimeError("No writable BLE characteristic found on target device")
110
+
111
+ print(f"Using characteristic: {char_uuid} (response_required={response_required})")
112
+
113
+ chunk_size = max(20, min(args.chunk_size, 512))
114
+ delay_sec = max(0, args.delay_ms) / 1000.0
115
+
116
+ bytes_sent = 0
117
+ for idx in range(0, len(payload), chunk_size):
118
+ chunk = payload[idx : idx + chunk_size]
119
+ print(f"[DEBUG] Writing {len(chunk)} bytes to {char_uuid}...")
120
+ await client.write_gatt_char(char_uuid, chunk, response=response_required)
121
+ bytes_sent += len(chunk)
122
+ if delay_sec:
123
+ await asyncio.sleep(delay_sec)
124
+
125
+ print(f"BLE print sent successfully ({bytes_sent} bytes)")
126
+ return
127
+ except Exception as exc:
128
+ last_error = exc
129
+ print(f"Connect/write attempt {attempt} failed: {repr(exc)}", file=sys.stderr)
130
+ if attempt < max(1, args.connect_retries):
131
+ await asyncio.sleep(1)
132
+ raise last_error if last_error else RuntimeError("BLE print failed without a specific error")
133
+
134
+
135
+ def main():
136
+ args = parse_args()
137
+ try:
138
+ asyncio.run(run_print(args))
139
+ except Exception as exc:
140
+ detail = str(exc) or repr(exc)
141
+ print(f"BLE print failed: {detail}", file=sys.stderr)
142
+ print(traceback.format_exc(), file=sys.stderr)
143
+ sys.exit(1)
144
+
145
+
146
+ if __name__ == "__main__":
147
+ main()
package/ble_scan.py ADDED
@@ -0,0 +1,72 @@
1
+ import argparse
2
+ import asyncio
3
+ import sys
4
+
5
+ from bleak import BleakClient, BleakScanner
6
+
7
+
8
+ def parse_args():
9
+ parser = argparse.ArgumentParser(description="Scan BLE devices and list writable characteristics")
10
+ parser.add_argument("--name-filter", default="", help="Device name contains filter")
11
+ parser.add_argument("--timeout", type=float, default=10.0, help="Scan timeout seconds")
12
+ parser.add_argument("--connect-timeout", type=float, default=15.0, help="Connect timeout seconds")
13
+ return parser.parse_args()
14
+
15
+
16
+ async def run_scan(args):
17
+ print("Scanning for BLE devices...")
18
+ devices = await BleakScanner.discover(timeout=args.timeout)
19
+
20
+ if not devices:
21
+ print("No BLE devices found.")
22
+ return
23
+
24
+ filtered = [d for d in devices if not args.name_filter or (args.name_filter.lower() in (d.name or "").lower())]
25
+
26
+ if not filtered:
27
+ print(f"No devices matching filter '{args.name_filter}'")
28
+ return
29
+
30
+ print(f"\nFound {len(filtered)} device(s):\n")
31
+
32
+ for device in filtered:
33
+ print(f"Device: {device.name} ({device.address})")
34
+
35
+ try:
36
+ async with BleakClient(device, timeout=args.connect_timeout) as client:
37
+ if hasattr(client, "get_services"):
38
+ services = await client.get_services()
39
+ else:
40
+ services = client.services
41
+
42
+ if not services:
43
+ print(" No services found")
44
+ continue
45
+
46
+ for service in services:
47
+ print(f" Service: {service.uuid}")
48
+
49
+ for char in service.characteristics:
50
+ props = ", ".join(char.properties)
51
+ writable = "write" in char.properties or "write-without-response" in char.properties
52
+ marker = " [WRITABLE]" if writable else ""
53
+ print(f" Characteristic: {char.uuid}{marker}")
54
+ print(f" Properties: {props}")
55
+
56
+ except Exception as exc:
57
+ print(f" Error connecting: {exc}")
58
+
59
+ print()
60
+
61
+
62
+ def main():
63
+ args = parse_args()
64
+ try:
65
+ asyncio.run(run_scan(args))
66
+ except Exception as exc:
67
+ print(f"Scan failed: {exc}", file=sys.stderr)
68
+ sys.exit(1)
69
+
70
+
71
+ if __name__ == "__main__":
72
+ main()
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "node-thermal-printer-js",
3
+ "version": "1.0.0",
4
+ "description": "ESC/POS printer helper for PSF588 Bluetooth and COM printing.",
5
+ "main": "app.js",
6
+ "exports": {
7
+ ".": "./app.js"
8
+ },
9
+ "files": [
10
+ "app.js",
11
+ "ble_print.py",
12
+ "ble_scan.py",
13
+ "README.md"
14
+ ],
15
+ "scripts": {
16
+ "test": "node test-api.js",
17
+ "dev": "node --watch app.js",
18
+ "pack:dry": "npm pack --dry-run"
19
+ },
20
+ "keywords": [
21
+ "printer",
22
+ "escpos",
23
+ "bluetooth",
24
+ "serialport",
25
+ "psf588"
26
+ ],
27
+ "author": "",
28
+ "license": "ISC",
29
+ "type": "module",
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "dependencies": {
34
+ "escpos": "^3.0.0-alpha.6",
35
+ "escpos-serialport": "^3.0.0-alpha.4",
36
+ "serialport": "^13.0.0"
37
+ }
38
+ }