node-thermal-printer-js 1.0.8 → 1.2.1

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/app.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import "dotenv/config";
2
- import { spawn } from "node:child_process";
2
+ import { spawn, spawnSync, execSync } from "node:child_process";
3
+ import { createConnection, createServer } from "node:net";
3
4
  import { fileURLToPath } from "node:url";
5
+ import { existsSync } from "node:fs";
4
6
  import path from "node:path";
5
7
 
6
8
  const getEscPosPayload = (data) =>
@@ -11,6 +13,185 @@ const getEscPosPayload = (data) =>
11
13
 
12
14
  const scriptDir = path.dirname(fileURLToPath(import.meta.url));
13
15
  const bleScriptPath = path.join(scriptDir, "ble_print.py");
16
+ const bleServerPath = path.join(scriptDir, "ble_server.py");
17
+
18
+ // Global server process reference
19
+ let bleServerProcess = null;
20
+ let bleServerHost = null;
21
+ let bleServerPort = null;
22
+
23
+ /**
24
+ * Find an available Python 3.9+ executable on the system.
25
+ * Tries local venv first, then versioned launcher probes on Windows, then system Python.
26
+ * @returns {{ cmd: string, args: string[] }|null} Command descriptor if found, null otherwise
27
+ */
28
+ const findPythonCmd = () => {
29
+ const candidates = getPythonLaunchCandidates();
30
+
31
+ for (const candidate of candidates) {
32
+ try {
33
+ const result = spawnSync(candidate.cmd, [...candidate.args, "--version"], {
34
+ encoding: "utf8",
35
+ timeout: 2000,
36
+ shell: false,
37
+ });
38
+
39
+ const output = `${result.stdout || ""}${result.stderr || ""}`.trim();
40
+ const match = output.match(/Python (\d+)\.(\d+)/);
41
+ if (match) {
42
+ const major = parseInt(match[1], 10);
43
+ const minor = parseInt(match[2], 10);
44
+ // Check if Python 3.9+
45
+ if (major > 3 || (major === 3 && minor >= 9)) {
46
+ console.log(`[BLE] ✓ Detected Python: ${candidate.cmd} ${candidate.args.join(" ")} (${output})`);
47
+ return candidate;
48
+ }
49
+ }
50
+ } catch {
51
+ // Continue to next candidate
52
+ }
53
+ }
54
+ return null;
55
+ };
56
+
57
+ const getPythonLaunchCandidates = () => {
58
+ const localVenvCandidates = process.platform === "win32"
59
+ ? [
60
+ { cmd: path.join(scriptDir, ".venv", "Scripts", "python.exe"), args: [] },
61
+ { cmd: path.join(scriptDir, ".venv", "Scripts", "python"), args: [] },
62
+ ]
63
+ : [
64
+ { cmd: path.join(scriptDir, ".venv", "bin", "python"), args: [] },
65
+ { cmd: path.join(scriptDir, ".venv", "bin", "python3"), args: [] },
66
+ ];
67
+
68
+ const systemCandidates = process.platform === "win32"
69
+ ? [
70
+ { cmd: "py", args: ["-3.11"] },
71
+ { cmd: "py", args: ["-3"] },
72
+ { cmd: "py", args: [] },
73
+ { cmd: "python3", args: [] },
74
+ { cmd: "python", args: [] },
75
+ ]
76
+ : [
77
+ { cmd: "python3", args: [] },
78
+ { cmd: "python", args: [] },
79
+ { cmd: "py", args: [] },
80
+ ];
81
+
82
+ return [
83
+ ...localVenvCandidates.filter((candidate) => existsSync(candidate.cmd)),
84
+ ...systemCandidates,
85
+ ];
86
+ };
87
+
88
+ /**
89
+ * Kill any stale BLE server processes still bound to a port.
90
+ * Prevents "Address already in use" errors on reinstall or crash recovery.
91
+ * @param {number} port Port number to clean up
92
+ * @param {number} timeout Timeout in ms for the operation
93
+ */
94
+ const killStaleProcesses = async (port, timeout = 3000) => {
95
+ const platformCmd = process.platform === "win32"
96
+ ? `netstat -ano | findstr :${port}`
97
+ : `lsof -ti:${port}`;
98
+
99
+ try {
100
+ const result = execSync(platformCmd, {
101
+ encoding: "utf8",
102
+ stdio: "pipe",
103
+ timeout,
104
+ shell: true,
105
+ }).trim();
106
+
107
+ if (!result) {
108
+ console.log(`[BLE] Port ${port} is clean`);
109
+ return;
110
+ }
111
+
112
+ console.warn(`[BLE] ⚠ Found stale process on port ${port}, cleaning up...`);
113
+
114
+ if (process.platform === "win32") {
115
+ // Extract PID from netstat output: "TCP 127.0.0.1:5555 0.0.0.0:0 LISTENING 12345"
116
+ const lines = result.split("\n");
117
+ const pids = new Set();
118
+ lines.forEach((line) => {
119
+ const parts = line.trim().split(/\s+/);
120
+ if (parts.length > 0) {
121
+ const pid = parts[parts.length - 1];
122
+ if (/^\d+$/.test(pid)) pids.add(pid);
123
+ }
124
+ });
125
+
126
+ for (const pid of pids) {
127
+ try {
128
+ execSync(`taskkill /PID ${pid} /F`, { timeout: 2000, stdio: "pipe" });
129
+ console.log(`[BLE] ✓ Killed PID ${pid}`);
130
+ } catch (e) {
131
+ console.warn(`[BLE] Could not kill PID ${pid}:`, e.message);
132
+ }
133
+ }
134
+ } else {
135
+ // On Unix-like systems, lsof returns just the PID
136
+ const pids = result.split("\n").filter((p) => p);
137
+ for (const pid of pids) {
138
+ try {
139
+ execSync(`kill -9 ${pid}`, { timeout: 2000, stdio: "pipe" });
140
+ console.log(`[BLE] ✓ Killed PID ${pid}`);
141
+ } catch (e) {
142
+ console.warn(`[BLE] Could not kill PID ${pid}:`, e.message);
143
+ }
144
+ }
145
+ }
146
+ } catch (err) {
147
+ // If netstat/lsof fails, not critical - just log and continue
148
+ console.debug(`[BLE] Could not check for stale processes: ${err.message}`);
149
+ }
150
+ };
151
+
152
+ const findBindablePort = (host, preferredPort, strictPreferred = false) =>
153
+ new Promise((resolve, reject) => {
154
+ const probe = createServer();
155
+
156
+ const finishWithEphemeral = () => {
157
+ const fallback = createServer();
158
+ fallback.once("error", reject);
159
+ fallback.listen(0, host, () => {
160
+ const address = fallback.address();
161
+ const port =
162
+ typeof address === "object" && address ? address.port : null;
163
+ fallback.close(() => {
164
+ if (!port) {
165
+ reject(
166
+ new Error("Unable to resolve an ephemeral port for BLE server"),
167
+ );
168
+ return;
169
+ }
170
+ resolve(port);
171
+ });
172
+ });
173
+ };
174
+
175
+ probe.once("error", (err) => {
176
+ if (strictPreferred || !preferredPort) {
177
+ reject(err);
178
+ return;
179
+ }
180
+ finishWithEphemeral();
181
+ });
182
+
183
+ probe.listen(preferredPort || 0, host, () => {
184
+ const address = probe.address();
185
+ const port = typeof address === "object" && address ? address.port : null;
186
+ probe.close(() => {
187
+ if (!port) {
188
+ reject(new Error("Unable to resolve a bindable port for BLE server"));
189
+ return;
190
+ }
191
+ resolve(port);
192
+ });
193
+ });
194
+ });
14
195
 
15
196
  const runPythonProcess = ({ cmd, cmdArgs, scriptArgs }) =>
16
197
  new Promise((resolve, reject) => {
@@ -57,6 +238,320 @@ const runPythonProcess = ({ cmd, cmdArgs, scriptArgs }) =>
57
238
  });
58
239
  });
59
240
 
241
+ const startBleServer = async (options = {}) => {
242
+ if (bleServerProcess) {
243
+ console.log("[BLE] Server already running");
244
+ return;
245
+ }
246
+
247
+ const detectedPythonCmd = findPythonCmd();
248
+ if (!detectedPythonCmd) {
249
+ console.warn(
250
+ "[BLE] Python auto-detection did not find a preferred launcher. Trying fallback candidates, including the project virtualenv if present.",
251
+ );
252
+ }
253
+
254
+ const host =
255
+ options.host || process.env.PRINTER_BLE_SERVER_HOST || "127.0.0.1";
256
+ const configuredPort = options.port || process.env.PRINTER_BLE_SERVER_PORT;
257
+ const preferredPort = configuredPort ? Number(configuredPort) : 5555;
258
+ const strictPreferred = Boolean(configuredPort);
259
+
260
+ // Clean up any stale processes before trying to bind
261
+ if (!strictPreferred) {
262
+ await killStaleProcesses(preferredPort, 3000).catch((err) => {
263
+ console.debug("[BLE] Stale process cleanup warning:", err.message);
264
+ });
265
+ }
266
+
267
+ const selectedPort = await findBindablePort(
268
+ host,
269
+ preferredPort,
270
+ strictPreferred,
271
+ );
272
+
273
+ if (selectedPort !== preferredPort && !strictPreferred) {
274
+ console.warn(
275
+ `[BLE] Port ${preferredPort} busy. Switching BLE server to free port ${selectedPort}.`,
276
+ );
277
+ }
278
+
279
+ bleServerHost = host;
280
+ bleServerPort = selectedPort;
281
+
282
+ const args = [
283
+ bleServerPath,
284
+ "--name",
285
+ options.bleName || process.env.PRINTER_BLE_NAME || "PSF588",
286
+ "--port",
287
+ String(selectedPort),
288
+ "--host",
289
+ host,
290
+ ];
291
+
292
+ if (options.bleAddress || process.env.PRINTER_BLE_ADDRESS) {
293
+ args.push(
294
+ "--address",
295
+ options.bleAddress || process.env.PRINTER_BLE_ADDRESS,
296
+ );
297
+ }
298
+
299
+ if (options.charUUID || process.env.PRINTER_BLE_CHAR_UUID) {
300
+ args.push(
301
+ "--char-uuid",
302
+ options.charUUID || process.env.PRINTER_BLE_CHAR_UUID,
303
+ );
304
+ }
305
+
306
+ if (options.chunkSize || process.env.PRINTER_BLE_CHUNK_SIZE) {
307
+ args.push(
308
+ "--chunk-size",
309
+ String(options.chunkSize || process.env.PRINTER_BLE_CHUNK_SIZE),
310
+ );
311
+ }
312
+
313
+ if (
314
+ options.delayMs !== undefined ||
315
+ process.env.PRINTER_BLE_DELAY_MS !== undefined
316
+ ) {
317
+ args.push(
318
+ "--delay-ms",
319
+ String(options.delayMs ?? process.env.PRINTER_BLE_DELAY_MS ?? 0),
320
+ );
321
+ }
322
+
323
+ if (options.connectTimeout || process.env.PRINTER_BLE_CONNECT_TIMEOUT) {
324
+ args.push(
325
+ "--connect-timeout",
326
+ String(options.connectTimeout || process.env.PRINTER_BLE_CONNECT_TIMEOUT),
327
+ );
328
+ }
329
+
330
+ if (options.scanTimeout || process.env.PRINTER_BLE_SCAN_TIMEOUT) {
331
+ args.push(
332
+ "--scan-timeout",
333
+ String(options.scanTimeout || process.env.PRINTER_BLE_SCAN_TIMEOUT),
334
+ );
335
+ }
336
+
337
+ if (options.pair || process.env.PRINTER_BLE_PAIR === "1") {
338
+ args.push("--pair");
339
+ }
340
+
341
+ const envCmd = options.pythonCmd || process.env.PRINTER_PYTHON_CMD;
342
+ const candidates = envCmd
343
+ ? [{ cmd: envCmd, cmdArgs: ["-u"] }] // -u for unbuffered output
344
+ : [
345
+ ...(detectedPythonCmd ? [{ cmd: detectedPythonCmd.cmd, cmdArgs: [...detectedPythonCmd.args, "-u"] }] : []),
346
+ ...getPythonLaunchCandidates().map((cmd) => ({ cmd, cmdArgs: ["-u"] })),
347
+ ];
348
+
349
+ return new Promise((resolve, reject) => {
350
+ let lastError;
351
+ let serverReady = false;
352
+
353
+ const tryStart = (index) => {
354
+ if (index >= candidates.length) {
355
+ return reject(
356
+ new Error(
357
+ `Failed to start BLE server. Errors: ${lastError?.message}`,
358
+ ),
359
+ );
360
+ }
361
+
362
+ const candidate = candidates[index];
363
+ console.log(
364
+ `[BLE] Attempting to start server with: ${candidate.cmd} ${candidate.cmdArgs.join(" ")} ...`,
365
+ );
366
+
367
+ const child = spawn(candidate.cmd, [...candidate.cmdArgs, ...args], {
368
+ stdio: "pipe",
369
+ detached: false,
370
+ shell: false,
371
+ });
372
+
373
+ console.log(`[BLE] Process spawned with PID ${child.pid}`);
374
+
375
+ child.on("error", (err) => {
376
+ lastError = new Error(
377
+ `Launch error for ${candidate.cmd}: ${err.message}`,
378
+ );
379
+ console.error(`[BLE] ${lastError.message}`);
380
+ tryStart(index + 1);
381
+ });
382
+
383
+ let stdoutBuffer = "";
384
+ let stderrBuffer = "";
385
+
386
+ child.stdout.on("data", (data) => {
387
+ const chunk = data.toString();
388
+ stdoutBuffer += chunk;
389
+ process.stdout.write(`[BLE-OUT] ${chunk}`);
390
+
391
+ if (chunk.includes("Server ready") && chunk.includes("Listening on")) {
392
+ if (!serverReady) {
393
+ serverReady = true;
394
+ bleServerProcess = child;
395
+ console.log(
396
+ `[BLE] ✓ Server ready and listening on ${bleServerHost}:${bleServerPort}!`,
397
+ );
398
+ resolve();
399
+ }
400
+ }
401
+ });
402
+
403
+ child.stderr.on("data", (data) => {
404
+ const chunk = data.toString();
405
+ stderrBuffer += chunk;
406
+ process.stderr.write(`[BLE-ERR] ${chunk}`);
407
+
408
+ if (chunk.includes("Server ready") && chunk.includes("Listening on")) {
409
+ if (!serverReady) {
410
+ serverReady = true;
411
+ bleServerProcess = child;
412
+ console.log(
413
+ `[BLE] ✓ Server ready and listening on ${bleServerHost}:${bleServerPort}!`,
414
+ );
415
+ resolve();
416
+ }
417
+ }
418
+ });
419
+
420
+ child.on("close", (code) => {
421
+ if (!serverReady) {
422
+ lastError = new Error(
423
+ `Server exited with code ${code}. Stdout: ${stdoutBuffer}. Stderr: ${stderrBuffer}`,
424
+ );
425
+ console.error(`[BLE] ${lastError.message}`);
426
+ tryStart(index + 1);
427
+ }
428
+ });
429
+
430
+ // Hard timeout: if server doesn't report ready in 20 seconds, fail
431
+ const timeoutId = setTimeout(() => {
432
+ if (!serverReady) {
433
+ console.error("[BLE] Server startup timeout (20s) - killing process");
434
+ lastError = new Error(
435
+ "Server startup timeout (no 'Server ready' message received)",
436
+ );
437
+ try {
438
+ child.kill("SIGTERM");
439
+ } catch (e) {
440
+ console.error("[BLE] Error killing process:", e.message);
441
+ }
442
+ setTimeout(() => tryStart(index + 1), 500);
443
+ }
444
+ }, 20000);
445
+
446
+ // Cleanup timeout if server becomes ready
447
+ child.once("close", () => clearTimeout(timeoutId));
448
+ };
449
+
450
+ tryStart(0);
451
+ });
452
+ };
453
+
454
+ const stopBleServer = async () => {
455
+ if (bleServerProcess) {
456
+ bleServerProcess.kill();
457
+ bleServerProcess = null;
458
+ bleServerHost = null;
459
+ bleServerPort = null;
460
+ console.log("[BLE] Server stopped");
461
+ }
462
+ };
463
+
464
+ const sendToBleServer = (request, options = {}) => {
465
+ const host =
466
+ options.host ||
467
+ bleServerHost ||
468
+ process.env.PRINTER_BLE_SERVER_HOST ||
469
+ "127.0.0.1";
470
+ const port =
471
+ options.port ||
472
+ bleServerPort ||
473
+ process.env.PRINTER_BLE_SERVER_PORT ||
474
+ 5555;
475
+ const timeoutMs = Number(
476
+ options.requestTimeoutMs ||
477
+ process.env.PRINTER_BLE_REQUEST_TIMEOUT_MS ||
478
+ 40000,
479
+ );
480
+
481
+ return new Promise((resolve, reject) => {
482
+ let settled = false;
483
+ let responseBuffer = "";
484
+
485
+ const settleResolve = (value) => {
486
+ if (settled) return;
487
+ settled = true;
488
+ resolve(value);
489
+ };
490
+
491
+ const settleReject = (error) => {
492
+ if (settled) return;
493
+ settled = true;
494
+ reject(error);
495
+ };
496
+
497
+ const socket = createConnection({ host, port }, () => {
498
+ console.log(`[BLE] Socket connected to ${host}:${port}`);
499
+ const message = JSON.stringify(request) + "\n";
500
+ socket.end(message);
501
+ console.log(`[BLE] Sent request: ${JSON.stringify(request)}`);
502
+ });
503
+
504
+ socket.on("data", (data) => {
505
+ responseBuffer += data.toString("utf-8");
506
+
507
+ const newlineIndex = responseBuffer.indexOf("\n");
508
+ if (newlineIndex === -1) {
509
+ return;
510
+ }
511
+
512
+ const responseLine = responseBuffer.slice(0, newlineIndex).trim();
513
+ if (!responseLine) {
514
+ return;
515
+ }
516
+
517
+ try {
518
+ const response = JSON.parse(responseLine);
519
+ console.log(`[BLE] Received response: ${JSON.stringify(response)}`);
520
+ socket.end();
521
+ if (response.ok) {
522
+ settleResolve(response);
523
+ } else {
524
+ settleReject(
525
+ new Error(response.error || "Unknown error from server"),
526
+ );
527
+ }
528
+ } catch (err) {
529
+ socket.end();
530
+ settleReject(
531
+ new Error(`Failed to parse server response: ${err.message}`),
532
+ );
533
+ }
534
+ });
535
+
536
+ socket.on("error", (err) => {
537
+ console.error(`[BLE] Socket error: ${err.message}`);
538
+ settleReject(new Error(`Socket connection failed: ${err.message}`));
539
+ });
540
+
541
+ socket.on("close", () => {
542
+ console.log("[BLE] Socket closed");
543
+ });
544
+
545
+ socket.setTimeout(timeoutMs, () => {
546
+ console.error("[BLE] Socket timeout waiting for server response");
547
+ socket.destroy();
548
+ settleReject(
549
+ new Error(`BLE server request timeout after ${timeoutMs}ms`),
550
+ );
551
+ });
552
+ });
553
+ };
554
+
60
555
  const printViaComPort = async (data, options = {}) => {
61
556
  const portPath = options.portPath || process.env.PRINTER_COM_PORT;
62
557
  const baudRate = options.baudRate || 9600;
@@ -150,10 +645,10 @@ const printViaBleBridge = (data, options = {}) => {
150
645
  const candidates = envCmd
151
646
  ? [{ cmd: envCmd, cmdArgs: [] }]
152
647
  : [
153
- { cmd: "py", cmdArgs: ["-3.11"] },
154
- { cmd: "py", cmdArgs: ["-3"] },
155
- { cmd: "python", cmdArgs: [] },
156
- ];
648
+ { cmd: "py", cmdArgs: ["-3.11"] },
649
+ { cmd: "py", cmdArgs: ["-3"] },
650
+ { cmd: "python", cmdArgs: [] },
651
+ ];
157
652
 
158
653
  return new Promise(async (resolve, reject) => {
159
654
  const failures = [];
@@ -184,15 +679,75 @@ const printViaBleBridge = (data, options = {}) => {
184
679
  });
185
680
  };
186
681
 
682
+ const printViaBleServer = async (data, options = {}) => {
683
+ const payload = getEscPosPayload(data).toString("base64");
684
+
685
+ // Start server if not already running
686
+ if (!bleServerProcess && options.autoStart !== false) {
687
+ try {
688
+ await startBleServer(options);
689
+ // Give the server a moment to fully stabilize after reporting ready
690
+ await new Promise((resolve) => setTimeout(resolve, 500));
691
+ } catch (err) {
692
+ console.warn(
693
+ "[BLE] Failed to auto-start server, falling back to legacy method:",
694
+ err.message,
695
+ );
696
+ return printViaBleBridge(data, options);
697
+ }
698
+ }
699
+
700
+ // Send print request to server
701
+ const request = {
702
+ command: "print",
703
+ data_b64: payload,
704
+ };
705
+
706
+ try {
707
+ return await sendToBleServer(request, {
708
+ host: options.host || process.env.PRINTER_BLE_SERVER_HOST,
709
+ port: options.port || process.env.PRINTER_BLE_SERVER_PORT,
710
+ requestTimeoutMs:
711
+ options.requestTimeoutMs || process.env.PRINTER_BLE_REQUEST_TIMEOUT_MS,
712
+ });
713
+ } catch (err) {
714
+ console.warn(
715
+ "[BLE] Server print failed, falling back to legacy method:",
716
+ err.message,
717
+ );
718
+ return printViaBleBridge(data, options);
719
+ }
720
+ };
721
+
187
722
  // Print to a paired Bluetooth printer exposed as a Windows COM (RFCOMM) port.
188
723
  // Preferable on Windows: pair the PSF588 printer in OS Bluetooth settings
189
724
  // and note the outgoing COM port (e.g. COM5). Then call `printToPSF588(data, { portPath: 'COM5' })`.
190
725
  export const printData = (data, options = {}) => {
191
- const transport = options.transport || process.env.PRINTER_TRANSPORT;
726
+ const transport =
727
+ options.transport || process.env.PRINTER_TRANSPORT || "ble-server";
728
+
729
+ if (transport === "ble-server") {
730
+ // New: Use persistent BLE server (faster, recommended)
731
+ return printViaBleServer(data, options);
732
+ }
192
733
 
193
734
  if (transport === "ble") {
735
+ // Legacy: Spawn process for each print (slower)
194
736
  return printViaBleBridge(data, options);
195
737
  }
196
738
 
739
+ // Default: COM port
197
740
  return printViaComPort(data, options);
198
741
  };
742
+
743
+ // Lifecycle management exports
744
+ export const startPrinterServer = (options = {}) => startBleServer(options);
745
+ export const stopPrinterServer = () => stopBleServer();
746
+ export const getPrinterServerStatus = () => {
747
+ return {
748
+ running: !!bleServerProcess,
749
+ processInfo: bleServerProcess,
750
+ host: bleServerHost,
751
+ port: bleServerPort,
752
+ };
753
+ };