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