node-thermal-printer-js 1.0.8 → 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/ble_server.py ADDED
@@ -0,0 +1,345 @@
1
+ """
2
+ Persistent BLE Printer Server
3
+ Maintains a long-running connection pool to avoid reconnection overhead.
4
+ Listens for print jobs over a local socket and sends them optimally.
5
+ """
6
+
7
+ import argparse
8
+ import asyncio
9
+ import base64
10
+ import json
11
+ import sys
12
+ import traceback
13
+ from typing import Optional
14
+
15
+ from bleak import BleakClient, BleakScanner
16
+
17
+
18
+ class BLEPrinterServer:
19
+ def __init__(self, device_name: str = "PSF588", device_address: Optional[str] = None,
20
+ char_uuid: str = "49535343-8841-43f4-a8d4-ecbe34729bb3",
21
+ chunk_size: int = 244, delay_ms: int = 0, pair: bool = False,
22
+ connect_timeout: float = 15.0, scan_timeout: float = 10.0,
23
+ port: int = 5555, host: str = "127.0.0.1"):
24
+ self.device_name = device_name
25
+ self.device_address = device_address
26
+ self.char_uuid = char_uuid
27
+ self.chunk_size = max(20, min(chunk_size, 512))
28
+ self.delay_sec = max(0, delay_ms) / 1000.0
29
+ self.pair = pair
30
+ self.connect_timeout = connect_timeout
31
+ self.scan_timeout = scan_timeout
32
+ self.port = port
33
+ self.host = host
34
+ self.client: Optional[BleakClient] = None
35
+ self.response_required = False
36
+ self.is_running = True
37
+
38
+ def _resolve_characteristic_mode(self, services, preferred_uuid: Optional[str]) -> tuple[Optional[str], bool]:
39
+ """Resolve characteristic UUID and whether response is required."""
40
+ if preferred_uuid:
41
+ preferred_uuid_l = preferred_uuid.lower()
42
+ for service in services:
43
+ for char in service.characteristics:
44
+ if (char.uuid or "").lower() == preferred_uuid_l:
45
+ props = set(char.properties)
46
+ if "write-without-response" in props:
47
+ print(
48
+ f"[SERVER] Using configured characteristic: {char.uuid} (response_required=False)",
49
+ flush=True,
50
+ )
51
+ return char.uuid, False
52
+ if "write" in props:
53
+ print(
54
+ f"[SERVER] Using configured characteristic: {char.uuid} (response_required=True)",
55
+ flush=True,
56
+ )
57
+ return char.uuid, True
58
+ raise RuntimeError(
59
+ f"Configured characteristic {preferred_uuid} is not writable"
60
+ )
61
+
62
+ raise RuntimeError(
63
+ f"Configured characteristic {preferred_uuid} not found on target device"
64
+ )
65
+
66
+ return self._pick_characteristic(services)
67
+
68
+ async def find_device(self) -> tuple[Optional[str], Optional[str]]:
69
+ """Find BLE device by name or use cached address."""
70
+ if self.device_address:
71
+ return self.device_address, None
72
+
73
+ print(f"[SERVER] Scanning for device: {self.device_name}", flush=True)
74
+ devices = await BleakScanner.discover(timeout=self.scan_timeout)
75
+ name_filter = (self.device_name or "").lower()
76
+
77
+ for device in devices:
78
+ name = (device.name or "").lower()
79
+ if name_filter and name_filter in name:
80
+ print(f"[SERVER] Found device: {device.name} ({device.address})", flush=True)
81
+ return device.address, device.name
82
+
83
+ raise RuntimeError(
84
+ f"No BLE device found for name filter '{self.device_name}'. "
85
+ "Try setting --address explicitly."
86
+ )
87
+
88
+ async def connect_to_printer(self):
89
+ """Establish and maintain BLE connection."""
90
+ if self.client and self.client.is_connected:
91
+ return
92
+
93
+ address, found_name = await self.find_device()
94
+ self.device_address = address
95
+
96
+ print(f"[SERVER] Connecting to {address}...", flush=True)
97
+ self.client = BleakClient(
98
+ address,
99
+ timeout=self.connect_timeout,
100
+ pair=self.pair,
101
+ winrt={"use_cached_services": False},
102
+ )
103
+
104
+ await self.client.connect()
105
+ print(f"[SERVER] Connected to {address}", flush=True)
106
+
107
+ # Get services and pick writable characteristic
108
+ if hasattr(self.client, "get_services"):
109
+ services = await self.client.get_services()
110
+ else:
111
+ services = self.client.services
112
+
113
+ self.char_uuid, self.response_required = self._resolve_characteristic_mode(
114
+ services, self.char_uuid
115
+ )
116
+ if not self.char_uuid:
117
+ raise RuntimeError("No writable BLE characteristic found on target device")
118
+
119
+ print(f"[SERVER] Using characteristic: {self.char_uuid} (response_required={self.response_required})", flush=True)
120
+
121
+ def _pick_characteristic(self, services) -> tuple[Optional[str], bool]:
122
+ """Prefer write-without-response characteristics."""
123
+ candidates = []
124
+
125
+ for service in services:
126
+ for char in service.characteristics:
127
+ props = set(char.properties)
128
+ if "write-without-response" in props:
129
+ candidates.append((char.uuid, False))
130
+ elif "write" in props:
131
+ candidates.append((char.uuid, True))
132
+
133
+ # Return first write-without-response, then first write
134
+ for uuid, response_required in candidates:
135
+ if not response_required:
136
+ print(f"[SERVER] Selected write-without-response characteristic: {uuid}", flush=True)
137
+ return uuid, response_required
138
+
139
+ for uuid, response_required in candidates:
140
+ print(f"[SERVER] Selected write characteristic (requires response): {uuid}", flush=True)
141
+ return uuid, response_required
142
+
143
+ return None, None
144
+
145
+ async def send_print_data(self, payload: bytes) -> dict:
146
+ """Send print data to connected printer."""
147
+ try:
148
+ # Ensure connection is alive
149
+ if not self.client or not self.client.is_connected:
150
+ print(f"[SERVER] Device not connected, attempting to connect...", flush=True)
151
+ try:
152
+ await self.connect_to_printer()
153
+ except Exception as conn_err:
154
+ return {"ok": False, "error": f"Failed to connect to printer: {str(conn_err)}"}
155
+
156
+ print(f"[SERVER] Sending {len(payload)} bytes in chunks of {self.chunk_size}...", flush=True)
157
+ bytes_sent = 0
158
+
159
+ for idx in range(0, len(payload), self.chunk_size):
160
+ chunk = payload[idx : idx + self.chunk_size]
161
+ await asyncio.wait_for(
162
+ self.client.write_gatt_char(
163
+ self.char_uuid, chunk, response=self.response_required
164
+ ),
165
+ timeout=8.0,
166
+ )
167
+ bytes_sent += len(chunk)
168
+ if self.delay_sec:
169
+ await asyncio.sleep(self.delay_sec)
170
+
171
+ print(f"[SERVER] Print sent successfully ({bytes_sent} bytes)", flush=True)
172
+ return {"ok": True, "bytes_sent": bytes_sent}
173
+
174
+ except Exception as exc:
175
+ print(f"[SERVER] Send failed: {repr(exc)}", file=sys.stderr, flush=True)
176
+ self.client = None # Reset connection on error
177
+ return {"ok": False, "error": str(exc)}
178
+
179
+ async def handle_client(self, reader, writer):
180
+ """Handle incoming print requests from app.js."""
181
+ addr = writer.get_extra_info("peername")
182
+ print(f"[SERVER] Client connected: {addr}", flush=True)
183
+
184
+ try:
185
+ # One request per connection to avoid stream deadlocks.
186
+ print("[SERVER] Waiting for request line...", flush=True)
187
+ try:
188
+ line = await asyncio.wait_for(reader.readline(), timeout=20.0)
189
+ except asyncio.TimeoutError:
190
+ print("[SERVER] Request read timed out", flush=True)
191
+ response = {"ok": False, "error": "Timed out waiting for request"}
192
+ writer.write((json.dumps(response) + "\n").encode("utf-8"))
193
+ await writer.drain()
194
+ return
195
+
196
+ if not line:
197
+ print("[SERVER] Connection closed before request payload", flush=True)
198
+ return
199
+
200
+ print(f"[SERVER] Received raw line bytes: {len(line)}", flush=True)
201
+
202
+ try:
203
+ request = json.loads(line.decode("utf-8"))
204
+ command = request.get("command")
205
+
206
+ if command == "print":
207
+ print("[SERVER] Handling print command", flush=True)
208
+ payload_b64 = request.get("data_b64")
209
+ if not payload_b64:
210
+ response = {"ok": False, "error": "Missing data_b64"}
211
+ else:
212
+ try:
213
+ payload = base64.b64decode(payload_b64)
214
+ response = await self.send_print_data(payload)
215
+ except Exception as e:
216
+ response = {"ok": False, "error": f"Decode error: {str(e)}"}
217
+
218
+ elif command == "status":
219
+ is_connected = self.client and self.client.is_connected
220
+ response = {
221
+ "ok": True,
222
+ "connected": is_connected,
223
+ "device_address": self.device_address,
224
+ "char_uuid": self.char_uuid,
225
+ }
226
+
227
+ elif command == "ping":
228
+ response = {"ok": True, "message": "pong"}
229
+
230
+ else:
231
+ response = {"ok": False, "error": f"Unknown command: {command}"}
232
+
233
+ writer.write((json.dumps(response) + "\n").encode("utf-8"))
234
+ await writer.drain()
235
+ print(f"[SERVER] Response sent: {response}", flush=True)
236
+
237
+ except json.JSONDecodeError as e:
238
+ response = {"ok": False, "error": f"JSON decode error: {str(e)}"}
239
+ writer.write((json.dumps(response) + "\n").encode("utf-8"))
240
+ await writer.drain()
241
+
242
+ except Exception as exc:
243
+ print(f"[SERVER] Client error: {repr(exc)}", file=sys.stderr, flush=True)
244
+ finally:
245
+ writer.close()
246
+ await writer.wait_closed()
247
+ print(f"[SERVER] Client disconnected: {addr}", flush=True)
248
+
249
+ async def start_server(self):
250
+ """Start the socket server."""
251
+ print(f"[SERVER] Starting BLE server on {self.host}:{self.port}", flush=True)
252
+
253
+ try:
254
+ # Initial connection to printer (non-blocking, will retry on first request)
255
+ await self.connect_to_printer()
256
+ except Exception as exc:
257
+ print(f"[SERVER] Initial connection failed: {repr(exc)}", file=sys.stderr, flush=True)
258
+ print("[SERVER] Will retry on first client request", flush=True)
259
+
260
+ async def client_handler(reader, writer):
261
+ await self.handle_client(reader, writer)
262
+
263
+ try:
264
+ # Enable SO_REUSEADDR to allow reusing the port immediately
265
+ server = await asyncio.start_server(
266
+ client_handler, self.host, self.port,
267
+ reuse_address=True
268
+ )
269
+ except OSError as e:
270
+ if "already in use" in str(e) or "10048" in str(e):
271
+ print(f"[SERVER] Port {self.port} still in TIME_WAIT, retrying in 2 seconds...", file=sys.stderr, flush=True)
272
+ await asyncio.sleep(2)
273
+ server = await asyncio.start_server(
274
+ client_handler, self.host, self.port,
275
+ reuse_address=True
276
+ )
277
+ else:
278
+ raise
279
+
280
+ print(f"[SERVER] Server ready. Listening on {self.host}:{self.port}", flush=True)
281
+ sys.stdout.flush()
282
+ sys.stderr.flush()
283
+
284
+ async with server:
285
+ await server.serve_forever()
286
+
287
+ async def shutdown(self):
288
+ """Gracefully shutdown."""
289
+ self.is_running = False
290
+ if self.client:
291
+ await self.client.disconnect()
292
+ print("[SERVER] Disconnected from printer", flush=True)
293
+
294
+
295
+ def parse_args():
296
+ parser = argparse.ArgumentParser(description="Persistent BLE Printer Server")
297
+ parser.add_argument("--name", default="PSF588", help="BLE device name filter")
298
+ parser.add_argument("--address", default=None, help="BLE MAC/address (skips scan)")
299
+ parser.add_argument(
300
+ "--char-uuid",
301
+ default="49535343-8841-43f4-a8d4-ecbe34729bb3",
302
+ help="Writable characteristic UUID",
303
+ )
304
+ parser.add_argument("--chunk-size", type=int, default=244, help="Write chunk size (default: 244)")
305
+ parser.add_argument("--delay-ms", type=int, default=0, help="Delay between chunks in ms")
306
+ parser.add_argument("--pair", action="store_true", help="Request OS pairing")
307
+ parser.add_argument("--connect-timeout", type=float, default=15.0, help="Connect timeout seconds")
308
+ parser.add_argument("--scan-timeout", type=float, default=10.0, help="Scan timeout seconds")
309
+ parser.add_argument("--port", type=int, default=5555, help="Server port")
310
+ parser.add_argument("--host", default="127.0.0.1", help="Server host")
311
+ return parser.parse_args()
312
+
313
+
314
+ def main():
315
+ args = parse_args()
316
+ # Set unbuffered/line-buffered output immediately
317
+ import io
318
+ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, write_through=True)
319
+ sys.stderr = io.TextIOWrapper(sys.stderr.buffer, write_through=True)
320
+
321
+ print("[SERVER] Starting BLE server...", flush=True)
322
+ try:
323
+ server = BLEPrinterServer(
324
+ device_name=args.name,
325
+ device_address=args.address,
326
+ char_uuid=args.char_uuid,
327
+ chunk_size=args.chunk_size,
328
+ delay_ms=args.delay_ms,
329
+ pair=args.pair,
330
+ connect_timeout=args.connect_timeout,
331
+ scan_timeout=args.scan_timeout,
332
+ port=args.port,
333
+ host=args.host,
334
+ )
335
+ asyncio.run(server.start_server())
336
+ except KeyboardInterrupt:
337
+ print("\n[SERVER] Shutting down...", flush=True)
338
+ except Exception as exc:
339
+ print(f"Server error: {repr(exc)}", file=sys.stderr, flush=True)
340
+ print(traceback.format_exc(), file=sys.stderr, flush=True)
341
+ sys.exit(1)
342
+
343
+
344
+ if __name__ == "__main__":
345
+ main()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-thermal-printer-js",
3
- "version": "1.0.8",
3
+ "version": "1.2.0",
4
4
  "description": "ESC/POS printer helper for PSF588 Bluetooth and COM printing.",
5
5
  "main": "app.js",
6
6
  "exports": {
@@ -10,13 +10,19 @@
10
10
  "app.js",
11
11
  "ble_print.py",
12
12
  "ble_scan.py",
13
+ "ble_server.py",
14
+ "scripts/install-deps.js",
13
15
  "README.md"
14
16
  ],
15
17
  "scripts": {
18
+ "postinstall": "node scripts/install-deps.js",
16
19
  "test": "node test-api.js",
17
20
  "dev": "node --watch app.js",
18
21
  "pack:dry": "npm pack --dry-run"
19
22
  },
23
+ "engines": {
24
+ "node": ">=16.0.0"
25
+ },
20
26
  "keywords": [
21
27
  "printer",
22
28
  "escpos",
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Postinstall script to verify and auto-install Python dependencies
4
+ * Runs: npm install --> postinstall hook
5
+ */
6
+
7
+ import { spawnSync } from "node:child_process";
8
+ import { existsSync } from "node:fs";
9
+ import { fileURLToPath } from "node:url";
10
+ import path from "node:path";
11
+
12
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
13
+
14
+ const findPythonCmd = () => {
15
+ const localVenvCandidates =
16
+ process.platform === "win32"
17
+ ? [
18
+ path.join(scriptDir, ".venv", "Scripts", "python.exe"),
19
+ path.join(scriptDir, ".venv", "Scripts", "python"),
20
+ ]
21
+ : [
22
+ path.join(scriptDir, ".venv", "bin", "python"),
23
+ path.join(scriptDir, ".venv", "bin", "python3"),
24
+ ];
25
+
26
+ const candidates = [
27
+ ...localVenvCandidates.filter((candidate) => existsSync(candidate)),
28
+ ...(process.platform === "win32"
29
+ ? ["py", "python3", "python"]
30
+ : ["python3", "python", "py"]),
31
+ ];
32
+
33
+ for (const cmd of candidates) {
34
+ try {
35
+ const result = spawnSync(cmd, ["--version"], {
36
+ encoding: "utf8",
37
+ stdio: "pipe",
38
+ timeout: 2000,
39
+ shell: false,
40
+ });
41
+ const version = `${result.stdout || ""}${result.stderr || ""}`.trim();
42
+ // Check if Python 3.9+
43
+ const match = version.match(/Python (\d+\.\d+)/);
44
+ if (match && parseFloat(match[1]) >= 3.9) {
45
+ console.log(`āœ“ Found ${cmd} (${version})`);
46
+ return cmd;
47
+ }
48
+ } catch {
49
+ // Continue to next candidate
50
+ }
51
+ }
52
+
53
+ return null;
54
+ };
55
+
56
+ const installBleak = (pythonCmd) => {
57
+ console.log(`\nšŸ“¦ Installing bleak Python package...`);
58
+ try {
59
+ const result = spawnSync(pythonCmd, ["-m", "pip", "install", "bleak"], {
60
+ encoding: "utf8",
61
+ stdio: "inherit",
62
+ timeout: 120000,
63
+ shell: false,
64
+ });
65
+ if (result.status !== 0) {
66
+ throw new Error(`pip exited with code ${result.status ?? "unknown"}`);
67
+ }
68
+ console.log("āœ“ bleak installed successfully");
69
+ return true;
70
+ } catch (err) {
71
+ console.warn("⚠ Could not auto-install bleak");
72
+ return false;
73
+ }
74
+ };
75
+
76
+ const main = async () => {
77
+ console.log("\nšŸ”§ node-thermal-printer postinstall setup\n");
78
+
79
+ // Step 1: Find Python
80
+ console.log("1ļøāƒ£ Checking Python installation...");
81
+ const pythonCmd = findPythonCmd();
82
+
83
+ if (!pythonCmd) {
84
+ console.error("\nāŒ ERROR: Python 3.9+ not found!");
85
+ console.error(
86
+ "\nPlease install Python from https://www.python.org/downloads/",
87
+ );
88
+ console.error("After installing Python, run: npm install again\n");
89
+ process.exit(1);
90
+ }
91
+
92
+ console.log(` Using: ${pythonCmd}\n`);
93
+
94
+ // Step 2: Install bleak
95
+ console.log("2ļøāƒ£ Checking bleak dependency...");
96
+ try {
97
+ const importCheck = spawnSync(pythonCmd, ["-c", "import bleak"], {
98
+ stdio: "pipe",
99
+ timeout: 2000,
100
+ shell: false,
101
+ });
102
+ if (importCheck.status !== 0) {
103
+ throw new Error("bleak import check failed");
104
+ }
105
+ console.log("āœ“ bleak already installed\n");
106
+ } catch {
107
+ const installed = installBleak(pythonCmd);
108
+ if (!installed) {
109
+ console.error("\n⚠ Manual installation required:");
110
+ console.error(` ${pythonCmd} -m pip install bleak\n`);
111
+ }
112
+ }
113
+
114
+ // Step 3: Platform-specific notes
115
+ console.log("3ļøāƒ£ Platform-specific requirements:");
116
+ const platform = process.platform;
117
+ if (platform === "linux") {
118
+ console.log(" šŸ“‹ Linux detected - BLE requires group permissions:");
119
+ console.log(" sudo usermod -a -G dialout,plugdev $USER");
120
+ console.log(" (Log out and back in for changes to take effect)\n");
121
+ } else if (platform === "darwin") {
122
+ console.log(" šŸ“‹ macOS detected - Ensure Bluetooth is enabled\n");
123
+ } else if (platform === "win32") {
124
+ console.log(
125
+ " šŸ“‹ Windows detected - Ensure Bluetooth drivers are installed\n",
126
+ );
127
+ }
128
+
129
+ console.log("āœ… Setup complete! You can now use node-thermal-printer-js\n");
130
+ };
131
+
132
+ main().catch((err) => {
133
+ console.error("Error during setup:", err.message);
134
+ process.exit(1);
135
+ });