printerxpl-forge 6.2.0__py3-none-any.whl
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.
- nse/README.md +204 -0
- nse/__init__.py +6 -0
- nse/install_nse.py +412 -0
- nse/lib/printerxpl.lua +238 -0
- nse/scripts/cups-info.nse +74 -0
- nse/scripts/cups-queue-info.nse +43 -0
- nse/scripts/hp-printers-cve-2022-1026.nse +121 -0
- nse/scripts/http-device-mac.nse +107 -0
- nse/scripts/http-hp-ilo-info.nse +121 -0
- nse/scripts/http-info-xerox-enum.nse +101 -0
- nse/scripts/http-vuln-cve2022-1026.nse +158 -0
- nse/scripts/lexmark-config.nse +89 -0
- nse/scripts/pjl-ready-message.nse +106 -0
- nse/scripts/printer-banner.nse +217 -0
- nse/scripts/printer-cups-rce.nse +189 -0
- nse/scripts/printer-cve-detect.nse +279 -0
- nse/scripts/printer-discover.nse +205 -0
- nse/scripts/printer-firmware-exposed.nse +219 -0
- nse/scripts/printer-hp-pjl.nse +192 -0
- nse/scripts/printer-http-ews.nse +293 -0
- nse/scripts/printer-ipp-info.nse +235 -0
- nse/scripts/printer-lexmark-ipp.nse +203 -0
- nse/scripts/printer-passback.nse +204 -0
- nse/scripts/printer-pjl-info.nse +146 -0
- nse/scripts/printer-printnightmare.nse +211 -0
- nse/scripts/printer-snmp-info.nse +176 -0
- nse/scripts/printer-vuln-check.nse +256 -0
- nse/scripts/snmp-device-mac.nse +93 -0
- nse/scripts/snmp-info.nse +146 -0
- nse/scripts/snmp-sysdescr.nse +70 -0
- printerxpl_forge-6.2.0.dist-info/METADATA +919 -0
- printerxpl_forge-6.2.0.dist-info/RECORD +97 -0
- printerxpl_forge-6.2.0.dist-info/WHEEL +5 -0
- printerxpl_forge-6.2.0.dist-info/entry_points.txt +4 -0
- printerxpl_forge-6.2.0.dist-info/licenses/LICENSE +21 -0
- printerxpl_forge-6.2.0.dist-info/top_level.txt +4 -0
- src/assets/fonts/gunplay.pfa +1671 -0
- src/assets/fonts/kshandwrt.pfa +315 -0
- src/assets/fonts/laksoner.pfa +2402 -0
- src/assets/fonts/paintcans.pfa +9699 -0
- src/assets/fonts/stencilod.pfa +4076 -0
- src/assets/fonts/takecover.pfa +26138 -0
- src/assets/fonts/topsecret.pfa +6652 -0
- src/assets/fonts/whoa.pfa +773 -0
- src/assets/mibs/HOST-RESOURCES-MIB +1540 -0
- src/assets/mibs/Printer-MIB +4389 -0
- src/assets/mibs/README.md +9 -0
- src/assets/mibs/SNMPv2-MIB +854 -0
- src/assets/overlays/hacker.eps +596 -0
- src/assets/overlays/smiley.eps +214 -0
- src/assets/overlays/smiley2.eps +240 -0
- src/core/attack_orchestrator.py +1025 -0
- src/core/capabilities.py +323 -0
- src/core/destructive_audit.py +430 -0
- src/core/discovery.py +488 -0
- src/core/osdetect.py +74 -0
- src/core/poly_runner.py +579 -0
- src/core/printer.py +1426 -0
- src/main.py +2134 -0
- src/modules/install_printer.py +318 -0
- src/modules/login_bruteforce.py +852 -0
- src/modules/pcl.py +506 -0
- src/modules/pjl.py +3575 -0
- src/modules/print_job.py +1290 -0
- src/modules/ps.py +1102 -0
- src/payloads/__init__.py +98 -0
- src/payloads/assets/overlays/notice.eps +9 -0
- src/protocols/__init__.py +19 -0
- src/protocols/firmware.py +738 -0
- src/protocols/ipp.py +216 -0
- src/protocols/ipp_attacks.py +609 -0
- src/protocols/lpd.py +141 -0
- src/protocols/network_map.py +1004 -0
- src/protocols/raw.py +173 -0
- src/protocols/smb.py +359 -0
- src/protocols/ssrf_pivot.py +427 -0
- src/protocols/storage.py +587 -0
- src/ui/__init__.py +6 -0
- src/ui/interactive.py +742 -0
- src/ui/spinner.py +112 -0
- src/ui/tables.py +132 -0
- src/utils/banner_grabber.py +852 -0
- src/utils/codebook.py +456 -0
- src/utils/config.py +522 -0
- src/utils/cve_loader.py +158 -0
- src/utils/default_creds.py +134 -0
- src/utils/discovery_online.py +1327 -0
- src/utils/exploit_manager.py +805 -0
- src/utils/fuzzer.py +220 -0
- src/utils/helper.py +732 -0
- src/utils/local_printers.py +307 -0
- src/utils/ml_engine.py +491 -0
- src/utils/operators.py +474 -0
- src/utils/ports.py +234 -0
- src/utils/vuln_scanner.py +823 -0
- src/utils/wordlist_loader.py +412 -0
- src/version.py +36 -0
src/modules/print_job.py
ADDED
|
@@ -0,0 +1,1290 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
PrinterXPL-Forge — Smart Print Job Sender
|
|
5
|
+
=======================================
|
|
6
|
+
Sends files or raw data to a printer via:
|
|
7
|
+
- IPP / IPPS (TCP 631) — AirPrint-compatible, auto-TLS upgrade
|
|
8
|
+
- LPD (TCP 515) — legacy, RFC 1179, uses ESC/P for Epson inkjets
|
|
9
|
+
- RAW (TCP 9100) — JetDirect passthrough (HP/PCL printers)
|
|
10
|
+
|
|
11
|
+
Format handling (in order of preference):
|
|
12
|
+
.txt → JPEG via Pillow → ESC/P → PostScript
|
|
13
|
+
.jpg/.png/... → ESC/P bitmap via Pillow → raw bytes
|
|
14
|
+
.ps/.eps → sent as-is
|
|
15
|
+
.pcl → sent as-is
|
|
16
|
+
.pdf → Ghostscript → PostScript → raw PDF
|
|
17
|
+
.doc/.docx → LibreOffice → PDF → PostScript
|
|
18
|
+
any → raw binary stream
|
|
19
|
+
|
|
20
|
+
Smart protocol probing:
|
|
21
|
+
- IPP: tests plain TCP first; if 426/connection-reset → auto-retries with TLS
|
|
22
|
+
- LPD: converts payload to ESC/P (native Epson) to avoid stuck-print issues
|
|
23
|
+
- RAW: passthrough; works for HP/PCL printers with port 9100 open
|
|
24
|
+
|
|
25
|
+
Printer readiness:
|
|
26
|
+
- SNMP hrPrinterStatus is checked when pysnmp is installed
|
|
27
|
+
- If printer is busy/printing, a clear warning is shown before sending
|
|
28
|
+
"""
|
|
29
|
+
# Author : Andre Henrique (@mrhenrike)
|
|
30
|
+
# GitHub : https://github.com/mrhenrike
|
|
31
|
+
# LinkedIn : https://linkedin.com/in/mrhenrike
|
|
32
|
+
# X/Twitter : https://x.com/mrhenrike
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import io
|
|
37
|
+
import logging
|
|
38
|
+
import os
|
|
39
|
+
import shutil
|
|
40
|
+
import socket
|
|
41
|
+
import ssl
|
|
42
|
+
import struct
|
|
43
|
+
import subprocess
|
|
44
|
+
import tempfile
|
|
45
|
+
import time
|
|
46
|
+
from dataclasses import dataclass, field
|
|
47
|
+
from pathlib import Path
|
|
48
|
+
from typing import List, Optional, Tuple
|
|
49
|
+
|
|
50
|
+
_log = logging.getLogger('PrinterXPL-Forge.print_job')
|
|
51
|
+
|
|
52
|
+
UEL = b'\x1b%-12345X'
|
|
53
|
+
|
|
54
|
+
# ── IPP status codes ──────────────────────────────────────────────────────────
|
|
55
|
+
_IPP_OK = 0x0000
|
|
56
|
+
_IPP_STATUS_BUSY = 0x0503 # server-error-busy
|
|
57
|
+
_IPP_DEVICE_ERROR = 0x0507 # server-error-device-error
|
|
58
|
+
_IPP_FORMAT_NOT_SUPPORTED = 0x0408 # client-error-document-format-not-supported
|
|
59
|
+
_IPP_NOT_AUTHORIZED = 0x0403 # client-error-forbidden
|
|
60
|
+
_IPP_NOT_FOUND = 0x0406 # client-error-not-found
|
|
61
|
+
_IPP_OP_NOT_SUPPORTED = 0x0501 # server-error-operation-not-supported
|
|
62
|
+
|
|
63
|
+
_IPP_ERRORS = {
|
|
64
|
+
0x0400: "Bad Request",
|
|
65
|
+
0x0401: "Forbidden (authentication required)",
|
|
66
|
+
0x0402: "Not Authenticated",
|
|
67
|
+
0x0403: "Forbidden — printer may be hardened",
|
|
68
|
+
0x0404: "Not Found — printer-uri not recognized",
|
|
69
|
+
0x0405: "Request Too Large",
|
|
70
|
+
0x0406: "Printer URI not found",
|
|
71
|
+
0x0407: "Attributes or values not supported",
|
|
72
|
+
0x0408: "Document format not supported — printer rejects this MIME type",
|
|
73
|
+
0x0409: "URI scheme not supported",
|
|
74
|
+
0x040A: "Charset not supported",
|
|
75
|
+
0x040B: "Conflicting attributes",
|
|
76
|
+
0x040C: "Compression not supported",
|
|
77
|
+
0x040D: "Compressed document too large",
|
|
78
|
+
0x040E: "Document format error (corrupt/invalid data)",
|
|
79
|
+
0x040F: "Document access error",
|
|
80
|
+
0x0500: "Internal server error",
|
|
81
|
+
0x0501: "Operation not supported by printer",
|
|
82
|
+
0x0503: "Printer is busy — try again later",
|
|
83
|
+
0x0505: "Multiple document jobs not supported",
|
|
84
|
+
0x0506: "Printer device error",
|
|
85
|
+
0x0507: "Printer device error",
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ── Result ─────────────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class PrintJobResult:
|
|
93
|
+
"""Result of a print job submission."""
|
|
94
|
+
success: bool
|
|
95
|
+
protocol: str
|
|
96
|
+
host: str
|
|
97
|
+
port: int
|
|
98
|
+
file_path: str
|
|
99
|
+
file_size: int
|
|
100
|
+
job_id: Optional[int] = None
|
|
101
|
+
message: str = ''
|
|
102
|
+
error: str = ''
|
|
103
|
+
hint: str = '' # actionable hint for the operator
|
|
104
|
+
elapsed_ms: float = 0.0
|
|
105
|
+
|
|
106
|
+
def __str__(self) -> str:
|
|
107
|
+
status = 'OK' if self.success else 'FAILED'
|
|
108
|
+
out = (
|
|
109
|
+
f"[{status}] {self.protocol.upper()} {self.host}:{self.port} "
|
|
110
|
+
f"file={self.file_path} size={self.file_size}B "
|
|
111
|
+
f"elapsed={self.elapsed_ms:.0f}ms"
|
|
112
|
+
)
|
|
113
|
+
if self.job_id:
|
|
114
|
+
out += f" job_id={self.job_id}"
|
|
115
|
+
if self.message:
|
|
116
|
+
out += f" msg={self.message}"
|
|
117
|
+
if self.error:
|
|
118
|
+
out += f" err={self.error}"
|
|
119
|
+
if self.hint:
|
|
120
|
+
out += f" hint={self.hint}"
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class PrinterCapabilities:
|
|
126
|
+
"""Quick probe result for a printer's print-job capabilities."""
|
|
127
|
+
host: str
|
|
128
|
+
ipp_available: bool = False
|
|
129
|
+
ipp_requires_tls:bool = False
|
|
130
|
+
ipp_status: int = -1 # last IPP status code
|
|
131
|
+
lpd_available: bool = False
|
|
132
|
+
raw_available: bool = False
|
|
133
|
+
snmp_status: int = -1 # hrPrinterStatus (-1 = unknown)
|
|
134
|
+
formats: List[str] = field(default_factory=list)
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def printer_ready(self) -> bool:
|
|
138
|
+
"""True if SNMP says the printer is idle (3) or unknown (-1)."""
|
|
139
|
+
return self.snmp_status in (-1, 3)
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def printer_busy(self) -> bool:
|
|
143
|
+
return self.snmp_status == 4
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def best_protocol(self) -> str:
|
|
147
|
+
if self.ipp_available:
|
|
148
|
+
return 'ipp'
|
|
149
|
+
if self.lpd_available:
|
|
150
|
+
return 'lpd'
|
|
151
|
+
if self.raw_available:
|
|
152
|
+
return 'raw'
|
|
153
|
+
return ''
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ── SNMP helper ───────────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
def _snmp_printer_status(host: str, timeout: float = 3.0) -> int:
|
|
159
|
+
"""
|
|
160
|
+
Return hrPrinterStatus via SNMP v1 (OID 1.3.6.1.2.1.25.3.5.1.1.1).
|
|
161
|
+
Returns -1 on error or if pysnmp is not installed.
|
|
162
|
+
Status codes: 1=other 2=unknown 3=idle 4=printing 5=warmup
|
|
163
|
+
"""
|
|
164
|
+
try:
|
|
165
|
+
from pysnmp.hlapi import ( # type: ignore
|
|
166
|
+
SnmpEngine, CommunityData, UdpTransportTarget, ContextData,
|
|
167
|
+
ObjectType, ObjectIdentity, getCmd,
|
|
168
|
+
)
|
|
169
|
+
for ei, es, eI, vbs in getCmd(
|
|
170
|
+
SnmpEngine(),
|
|
171
|
+
CommunityData('public', mpModel=0),
|
|
172
|
+
UdpTransportTarget((host, 161), timeout=timeout, retries=0),
|
|
173
|
+
ContextData(),
|
|
174
|
+
ObjectType(ObjectIdentity('1.3.6.1.2.1.25.3.5.1.1.1')),
|
|
175
|
+
):
|
|
176
|
+
if ei or es:
|
|
177
|
+
return -1
|
|
178
|
+
for vb in vbs:
|
|
179
|
+
return int(vb[1])
|
|
180
|
+
except Exception:
|
|
181
|
+
pass
|
|
182
|
+
return -1
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
_SNMP_STATUS_LABEL = {
|
|
186
|
+
1: 'other',
|
|
187
|
+
2: 'unknown',
|
|
188
|
+
3: 'idle (ready)',
|
|
189
|
+
4: 'printing — BUSY',
|
|
190
|
+
5: 'warming up',
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ── Port probe ────────────────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
def _tcp_open(host: str, port: int, timeout: float = 4.0) -> bool:
|
|
197
|
+
"""Return True if the TCP port is open and accepts connections."""
|
|
198
|
+
try:
|
|
199
|
+
with socket.create_connection((host, port), timeout=timeout):
|
|
200
|
+
return True
|
|
201
|
+
except OSError:
|
|
202
|
+
return False
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _probe_ipp(host: str, port: int = 631, timeout: float = 6.0) -> Tuple[bool, bool]:
|
|
206
|
+
"""
|
|
207
|
+
Probe IPP endpoint on *port*.
|
|
208
|
+
Returns (available, requires_tls).
|
|
209
|
+
- available=True if the port accepts IPP connections
|
|
210
|
+
- requires_tls=True if plain TCP returns 426 or connection-reset (Epson behaviour)
|
|
211
|
+
"""
|
|
212
|
+
def _minimal_ipp() -> bytes:
|
|
213
|
+
"""Build a Get-Printer-Attributes request with no document payload."""
|
|
214
|
+
def _s(name: str, value: str, tag: int = 0x44) -> bytes:
|
|
215
|
+
nb = name.encode(); vb = value.encode()
|
|
216
|
+
return (bytes([tag]) + struct.pack('>H', len(nb)) + nb +
|
|
217
|
+
struct.pack('>H', len(vb)) + vb)
|
|
218
|
+
uri = f'ipp://{host}:{port}/ipp/print'
|
|
219
|
+
attrs = b'\x01'
|
|
220
|
+
attrs += _s('attributes-charset', 'utf-8', 0x47)
|
|
221
|
+
attrs += _s('attributes-natural-language', 'en-us', 0x48)
|
|
222
|
+
attrs += _s('printer-uri', uri, 0x45)
|
|
223
|
+
attrs += b'\x03'
|
|
224
|
+
hdr = struct.pack('>BBHI', 1, 1, 0x000B, 1)
|
|
225
|
+
req = hdr + attrs
|
|
226
|
+
return (
|
|
227
|
+
f'POST /ipp/print HTTP/1.1\r\nHost: {host}:{port}\r\n'
|
|
228
|
+
f'Content-Type: application/ipp\r\nContent-Length: {len(req)}\r\n'
|
|
229
|
+
f'Connection: close\r\n\r\n'
|
|
230
|
+
).encode() + req
|
|
231
|
+
|
|
232
|
+
ipp_req = _minimal_ipp()
|
|
233
|
+
|
|
234
|
+
# 1. Try plain TCP
|
|
235
|
+
try:
|
|
236
|
+
with socket.create_connection((host, port), timeout=timeout) as s:
|
|
237
|
+
s.settimeout(timeout)
|
|
238
|
+
s.sendall(ipp_req)
|
|
239
|
+
resp = b''
|
|
240
|
+
s.settimeout(3)
|
|
241
|
+
try:
|
|
242
|
+
while True:
|
|
243
|
+
c = s.recv(4096)
|
|
244
|
+
if not c:
|
|
245
|
+
break
|
|
246
|
+
resp += c
|
|
247
|
+
except socket.timeout:
|
|
248
|
+
pass
|
|
249
|
+
if resp.startswith(b'HTTP/1.1 426'):
|
|
250
|
+
return True, True # port open, needs TLS
|
|
251
|
+
if resp.startswith(b'HTTP/1.1'):
|
|
252
|
+
return True, False # port open, plain works
|
|
253
|
+
except ConnectionResetError:
|
|
254
|
+
# Epson resets non-TLS connections — treat as "TLS required"
|
|
255
|
+
pass
|
|
256
|
+
except OSError:
|
|
257
|
+
# Port not open
|
|
258
|
+
return False, False
|
|
259
|
+
|
|
260
|
+
# 2. Try TLS
|
|
261
|
+
ctx = ssl.create_default_context()
|
|
262
|
+
ctx.check_hostname = False
|
|
263
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
264
|
+
try:
|
|
265
|
+
raw = socket.create_connection((host, port), timeout=timeout)
|
|
266
|
+
with ctx.wrap_socket(raw, server_hostname=host) as s:
|
|
267
|
+
s.settimeout(timeout)
|
|
268
|
+
s.sendall(ipp_req)
|
|
269
|
+
resp = b''
|
|
270
|
+
s.settimeout(3)
|
|
271
|
+
try:
|
|
272
|
+
while True:
|
|
273
|
+
c = s.recv(4096)
|
|
274
|
+
if not c:
|
|
275
|
+
break
|
|
276
|
+
resp += c
|
|
277
|
+
except socket.timeout:
|
|
278
|
+
pass
|
|
279
|
+
if resp.startswith(b'HTTP/1.1'):
|
|
280
|
+
return True, True # TLS works
|
|
281
|
+
except OSError:
|
|
282
|
+
pass
|
|
283
|
+
|
|
284
|
+
return False, False
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def probe_printer(host: str, timeout: float = 5.0) -> PrinterCapabilities:
|
|
288
|
+
"""
|
|
289
|
+
Quickly probe a printer's available protocols and readiness.
|
|
290
|
+
Results are used by send_print_job() to choose the best protocol.
|
|
291
|
+
"""
|
|
292
|
+
caps = PrinterCapabilities(host=host)
|
|
293
|
+
|
|
294
|
+
# SNMP status (non-blocking, best-effort)
|
|
295
|
+
caps.snmp_status = _snmp_printer_status(host, timeout=min(timeout, 3.0))
|
|
296
|
+
|
|
297
|
+
# Protocol probes in parallel (sequential to keep deps simple)
|
|
298
|
+
ipp_ok, ipp_tls = _probe_ipp(host, 631, timeout=timeout)
|
|
299
|
+
caps.ipp_available = ipp_ok
|
|
300
|
+
caps.ipp_requires_tls = ipp_tls
|
|
301
|
+
|
|
302
|
+
caps.lpd_available = _tcp_open(host, 515, timeout=timeout)
|
|
303
|
+
caps.raw_available = _tcp_open(host, 9100, timeout=timeout)
|
|
304
|
+
|
|
305
|
+
_log.debug(
|
|
306
|
+
"Printer probe %s: IPP=%s(tls=%s) LPD=%s RAW=%s SNMP=%s",
|
|
307
|
+
host, ipp_ok, ipp_tls, caps.lpd_available, caps.raw_available,
|
|
308
|
+
_SNMP_STATUS_LABEL.get(caps.snmp_status, caps.snmp_status),
|
|
309
|
+
)
|
|
310
|
+
return caps
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# ── Format detection & conversion ─────────────────────────────────────────────
|
|
314
|
+
|
|
315
|
+
def _detect_format(path: str) -> str:
|
|
316
|
+
"""Return detected format string from file extension and magic bytes."""
|
|
317
|
+
p = Path(path)
|
|
318
|
+
ext_map = {
|
|
319
|
+
'.ps': 'ps', '.eps': 'ps', '.pcl': 'pcl', '.prn': 'raw',
|
|
320
|
+
'.pdf': 'pdf', '.txt': 'text', '.rtf': 'text',
|
|
321
|
+
'.png': 'image', '.jpg': 'image', '.jpeg': 'image',
|
|
322
|
+
'.bmp': 'image', '.gif': 'image', '.tiff': 'image', '.tif': 'image',
|
|
323
|
+
'.doc': 'word', '.docx': 'word', '.odt': 'word',
|
|
324
|
+
}
|
|
325
|
+
fmt = ext_map.get(p.suffix.lower(), 'raw')
|
|
326
|
+
try:
|
|
327
|
+
with open(path, 'rb') as f:
|
|
328
|
+
magic = f.read(8)
|
|
329
|
+
if magic[:4] == b'%PDF':
|
|
330
|
+
return 'pdf'
|
|
331
|
+
if magic[:2] == b'%!':
|
|
332
|
+
return 'ps'
|
|
333
|
+
if magic[:2] == b'\x1b%':
|
|
334
|
+
return 'pcl'
|
|
335
|
+
if magic[:2] == b'\xff\xd8':
|
|
336
|
+
return 'image'
|
|
337
|
+
if magic[:8] == b'\x89PNG\r\n\x1a\n':
|
|
338
|
+
return 'image'
|
|
339
|
+
except OSError:
|
|
340
|
+
pass
|
|
341
|
+
return fmt
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _detect_mime(data: bytes) -> str:
|
|
345
|
+
"""Detect MIME type from magic bytes for IPP document-format."""
|
|
346
|
+
if data[:4] == b'%PDF':
|
|
347
|
+
return 'application/pdf'
|
|
348
|
+
if data[:2] == b'%!':
|
|
349
|
+
return 'application/postscript'
|
|
350
|
+
if data[:2] == b'\xff\xd8':
|
|
351
|
+
return 'image/jpeg'
|
|
352
|
+
if data[:8] == b'\x89PNG\r\n\x1a\n':
|
|
353
|
+
return 'image/png'
|
|
354
|
+
if data[:2] == b'BM':
|
|
355
|
+
return 'image/bmp'
|
|
356
|
+
if data[:6] in (b'GIF87a', b'GIF89a'):
|
|
357
|
+
return 'image/gif'
|
|
358
|
+
if data[:4] == b'\x1b%G':
|
|
359
|
+
return 'application/vnd.hp-PCL'
|
|
360
|
+
if data[:2] == b'\x1b@':
|
|
361
|
+
return 'application/vnd.epson.escpr' # ESC/P payload
|
|
362
|
+
return 'application/octet-stream'
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _text_to_ps(text: str, copies: int = 1) -> bytes:
|
|
366
|
+
"""Wrap plain text in a minimal PostScript document (laser printers)."""
|
|
367
|
+
lines = text.replace('\r\n', '\n').replace('\r', '\n').split('\n')
|
|
368
|
+
out = []
|
|
369
|
+
for _ in range(copies):
|
|
370
|
+
out += [
|
|
371
|
+
'%!PS-Adobe-3.0', '%%Pages: 1', '%%EndComments',
|
|
372
|
+
'/Courier 10 selectfont', '%%Page: 1 1',
|
|
373
|
+
]
|
|
374
|
+
y = 750
|
|
375
|
+
for line in lines[:72]:
|
|
376
|
+
safe = line.replace('\\', '\\\\').replace('(', '\\(').replace(')', '\\)')
|
|
377
|
+
out.append(f'72 {y} moveto')
|
|
378
|
+
out.append(f'({safe}) show')
|
|
379
|
+
y -= 14
|
|
380
|
+
if y < 50:
|
|
381
|
+
break
|
|
382
|
+
out += ['showpage', '%%Trailer', '%%EOF']
|
|
383
|
+
return '\n'.join(out).encode('latin-1', errors='replace')
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _text_to_escp(text: str, copies: int = 1) -> bytes:
|
|
387
|
+
"""
|
|
388
|
+
Render text as ESC/P stream — native Epson language, zero dependencies.
|
|
389
|
+
Works on all Epson inkjets and dot-matrix printers via LPD passthrough.
|
|
390
|
+
"""
|
|
391
|
+
ESC = b'\x1b'
|
|
392
|
+
buf = bytearray()
|
|
393
|
+
for _ in range(copies):
|
|
394
|
+
buf += ESC + b'@' # init / reset
|
|
395
|
+
buf += ESC + b'\x33\x18' # 24/180-inch line spacing
|
|
396
|
+
lines = text.replace('\r\n', '\n').replace('\r', '\n').split('\n')
|
|
397
|
+
for line in lines:
|
|
398
|
+
# Normalize common unicode punctuation to ASCII equivalents
|
|
399
|
+
normalized = (
|
|
400
|
+
line.replace('\u2014', '-') # em dash → hyphen
|
|
401
|
+
.replace('\u2013', '-') # en dash → hyphen
|
|
402
|
+
.replace('\u2018', "'") # left single quote
|
|
403
|
+
.replace('\u2019', "'") # right single quote
|
|
404
|
+
.replace('\u201c', '"') # left double quote
|
|
405
|
+
.replace('\u201d', '"') # right double quote
|
|
406
|
+
.replace('\u2026', '...') # ellipsis
|
|
407
|
+
.replace('\u00b7', '.') # middle dot
|
|
408
|
+
)
|
|
409
|
+
buf += normalized.encode('latin-1', errors='replace') + b'\r\n'
|
|
410
|
+
buf += b'\x0c' # form-feed — eject page
|
|
411
|
+
return bytes(buf)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _image_to_pwg_raster(img: object, dpi: int = 150, color: bool = True) -> bytes:
|
|
415
|
+
"""
|
|
416
|
+
Convert a PIL Image to PWG Raster (image/pwg-raster, RaS3 sync word).
|
|
417
|
+
|
|
418
|
+
PWG Raster is the AirPrint/Mopria native format accepted by Epson
|
|
419
|
+
L-series inkjets via IPP/IPPS. Field offsets follow cups_page_header2_t
|
|
420
|
+
(CUPS 2.x source: cups/raster.h).
|
|
421
|
+
|
|
422
|
+
Args:
|
|
423
|
+
img: PIL Image object.
|
|
424
|
+
dpi: Output resolution (150 is sufficient for inkjets).
|
|
425
|
+
color: True → srgb_8 (24-bit), False → sgray_8 (8-bit grayscale).
|
|
426
|
+
"""
|
|
427
|
+
try:
|
|
428
|
+
from PIL import Image as _PIL # type: ignore
|
|
429
|
+
except ImportError:
|
|
430
|
+
return b''
|
|
431
|
+
|
|
432
|
+
if color:
|
|
433
|
+
img = img.convert('RGB') # type: ignore[union-attr]
|
|
434
|
+
cs, bpc, bpp, nc = 19, 8, 24, 3 # sRGB
|
|
435
|
+
else:
|
|
436
|
+
img = img.convert('L') # type: ignore[union-attr]
|
|
437
|
+
cs, bpc, bpp, nc = 18, 8, 8, 1 # sGray
|
|
438
|
+
|
|
439
|
+
pw = int(8.27 * dpi)
|
|
440
|
+
ph = int(11.69 * dpi)
|
|
441
|
+
ratio = pw / img.width # type: ignore[union-attr]
|
|
442
|
+
new_h = min(int(img.height * ratio), ph) # type: ignore[union-attr]
|
|
443
|
+
img = img.resize((pw, new_h), resample=3) # type: ignore[union-attr]
|
|
444
|
+
W, H = img.size # type: ignore[union-attr]
|
|
445
|
+
bpl = W * (bpp // 8)
|
|
446
|
+
|
|
447
|
+
hdr = bytearray(1796)
|
|
448
|
+
|
|
449
|
+
def _u32(off: int, v: int) -> None:
|
|
450
|
+
struct.pack_into('>I', hdr, off, v)
|
|
451
|
+
|
|
452
|
+
def _f32(off: int, v: float) -> None:
|
|
453
|
+
struct.pack_into('>f', hdr, off, v)
|
|
454
|
+
|
|
455
|
+
def _stn(off: int, s: str, ln: int = 64) -> None:
|
|
456
|
+
b = s.encode('ascii')[:ln - 1]
|
|
457
|
+
hdr[off:off + len(b)] = b
|
|
458
|
+
hdr[off + len(b)] = 0
|
|
459
|
+
|
|
460
|
+
# String fields (4 × 64 B = offsets 0-255)
|
|
461
|
+
_stn(0, 'PwgRaster') # MediaClass — REQUIRED for PWG Raster (RaS3)
|
|
462
|
+
_stn(64, '')
|
|
463
|
+
_stn(128, 'Plain')
|
|
464
|
+
_stn(192, '') # OutputType — must be empty
|
|
465
|
+
|
|
466
|
+
# uint32 / float fields (offsets 256+)
|
|
467
|
+
for off in range(256, 276, 4):
|
|
468
|
+
_u32(off, 0)
|
|
469
|
+
_u32(276, dpi); _u32(280, dpi) # HWResolution x, y
|
|
470
|
+
_u32(284, 0); _u32(288, 0); _u32(292, 595); _u32(296, 842) # ImagingBBox
|
|
471
|
+
for off in range(300, 340, 4):
|
|
472
|
+
_u32(off, 0)
|
|
473
|
+
_u32(340, 1) # NumCopies
|
|
474
|
+
_u32(344, 0) # Orientation (portrait)
|
|
475
|
+
_u32(348, 0) # OutputFaceUp
|
|
476
|
+
_u32(352, 595); _u32(356, 842) # PageSize A4 in pts (1 pt = 1/72 in)
|
|
477
|
+
for off in range(360, 372, 4):
|
|
478
|
+
_u32(off, 0)
|
|
479
|
+
_u32(372, W); _u32(376, H) # cupsWidth, cupsHeight (pixels)
|
|
480
|
+
_u32(380, 0)
|
|
481
|
+
_u32(384, bpc); _u32(388, bpp); _u32(392, bpl)
|
|
482
|
+
_u32(396, 0) # cupsColorOrder (chunked)
|
|
483
|
+
_u32(400, cs) # cupsColorSpace (18=sGray, 19=sRGB)
|
|
484
|
+
_u32(404, 0) # cupsCompression (raw, uncompressed)
|
|
485
|
+
for off in range(408, 420, 4):
|
|
486
|
+
_u32(off, 0)
|
|
487
|
+
_u32(420, nc) # cupsNumColors
|
|
488
|
+
_f32(424, 0.0)
|
|
489
|
+
_f32(428, 595.28); _f32(432, 841.89) # cupsPageSize (pts, float)
|
|
490
|
+
_f32(436, 0.0); _f32(440, 0.0)
|
|
491
|
+
_f32(444, 595.28); _f32(448, 841.89) # cupsImagingBBox
|
|
492
|
+
_stn(1732, 'iso_a4_210x297mm') # cupsPageSizeName
|
|
493
|
+
|
|
494
|
+
raster = img.tobytes() # type: ignore[union-attr]
|
|
495
|
+
return b'RaS\x03' + bytes(hdr) + raster
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _image_to_escp_bitmap(img: object) -> bytes: # img: PIL.Image.Image
|
|
499
|
+
"""
|
|
500
|
+
Convert a PIL Image to ESC/P 24-pin bitmap stream (ESC * mode 39).
|
|
501
|
+
NOTE: This is a dot-matrix / legacy format — NOT suitable for Epson
|
|
502
|
+
inkjet printers (L-series, EcoTank). Use _image_to_pwg_raster() for
|
|
503
|
+
inkjets when printing via IPP/IPPS.
|
|
504
|
+
"""
|
|
505
|
+
ESC = b'\x1b'
|
|
506
|
+
|
|
507
|
+
gray = img.convert('L') # type: ignore[attr-defined]
|
|
508
|
+
max_w = 480
|
|
509
|
+
if gray.width > max_w:
|
|
510
|
+
ratio = max_w / gray.width
|
|
511
|
+
gray = gray.resize((int(gray.width * ratio), int(gray.height * ratio)), resample=1)
|
|
512
|
+
bw = gray.convert('1')
|
|
513
|
+
pix = bw.load()
|
|
514
|
+
width, height = bw.size
|
|
515
|
+
|
|
516
|
+
buf = bytearray()
|
|
517
|
+
buf += ESC + b'@' # init
|
|
518
|
+
buf += ESC + b'\x33\x18' # tight line spacing for bitmap
|
|
519
|
+
BAND = 24
|
|
520
|
+
for y0 in range(0, height, BAND):
|
|
521
|
+
cols = bytearray(width * 3)
|
|
522
|
+
for x in range(width):
|
|
523
|
+
for pin in range(BAND):
|
|
524
|
+
py = y0 + pin
|
|
525
|
+
if py < height and not pix[x, py]: # PIL 1-bit: 0 = black
|
|
526
|
+
bi = x * 3 + pin // 8
|
|
527
|
+
bit = 7 - pin % 8
|
|
528
|
+
cols[bi] |= 1 << bit
|
|
529
|
+
buf += ESC + b'*' + bytes([39])
|
|
530
|
+
buf += struct.pack('<H', width)
|
|
531
|
+
buf += bytes(cols)
|
|
532
|
+
buf += b'\r\n'
|
|
533
|
+
buf += b'\x0c'
|
|
534
|
+
return bytes(buf)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _text_to_jpeg(text: str, copies: int = 1) -> Optional[bytes]:
|
|
538
|
+
"""
|
|
539
|
+
Render text as JPEG via Pillow — best choice for AirPrint/IPP inkjets.
|
|
540
|
+
Returns None if Pillow is unavailable.
|
|
541
|
+
"""
|
|
542
|
+
try:
|
|
543
|
+
from PIL import Image, ImageDraw, ImageFont # type: ignore
|
|
544
|
+
W, H, MARGIN, LINE_H, FS = 2480, 3508, 150, 55, 40
|
|
545
|
+
try:
|
|
546
|
+
font = ImageFont.truetype('arial.ttf', FS)
|
|
547
|
+
except (IOError, OSError):
|
|
548
|
+
font = ImageFont.load_default()
|
|
549
|
+
|
|
550
|
+
lines = text.replace('\r\n', '\n').replace('\r', '\n').split('\n')
|
|
551
|
+
max_ln = (H - 2 * MARGIN) // LINE_H
|
|
552
|
+
pages = []
|
|
553
|
+
for start in range(0, max(1, len(lines)), max_ln):
|
|
554
|
+
img = Image.new('RGB', (W, H), (255, 255, 255))
|
|
555
|
+
draw = ImageDraw.Draw(img)
|
|
556
|
+
y = MARGIN
|
|
557
|
+
for ln in lines[start:start + max_ln]:
|
|
558
|
+
draw.text((MARGIN, y), ln, fill=(0, 0, 0), font=font)
|
|
559
|
+
y += LINE_H
|
|
560
|
+
pages.append(img)
|
|
561
|
+
|
|
562
|
+
result = b''
|
|
563
|
+
for img in pages * copies:
|
|
564
|
+
buf = io.BytesIO()
|
|
565
|
+
img.save(buf, format='JPEG', quality=85, dpi=(300, 300))
|
|
566
|
+
result += buf.getvalue()
|
|
567
|
+
return result
|
|
568
|
+
except ImportError:
|
|
569
|
+
return None
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _convert_to_ps(path: str) -> Optional[bytes]:
|
|
573
|
+
"""Convert PDF/image/Word to PostScript via Ghostscript or LibreOffice."""
|
|
574
|
+
fmt = _detect_format(path)
|
|
575
|
+
gs = shutil.which('gs') or shutil.which('gswin64c') or shutil.which('gswin32c')
|
|
576
|
+
lo = shutil.which('libreoffice') or shutil.which('soffice')
|
|
577
|
+
|
|
578
|
+
for src_fmt, tool, args in [
|
|
579
|
+
('pdf', gs, ['-q', '-dNOPAUSE', '-dBATCH', '-sDEVICE=pswrite']),
|
|
580
|
+
('image', gs, ['-q', '-dNOPAUSE', '-dBATCH', '-sDEVICE=pswrite', '-sPAPERSIZE=a4']),
|
|
581
|
+
]:
|
|
582
|
+
if fmt == src_fmt and tool:
|
|
583
|
+
try:
|
|
584
|
+
with tempfile.NamedTemporaryFile(suffix='.ps', delete=False) as tmp:
|
|
585
|
+
tp = tmp.name
|
|
586
|
+
r = subprocess.run([tool] + args + [f'-sOutputFile={tp}', path],
|
|
587
|
+
capture_output=True, timeout=30)
|
|
588
|
+
if r.returncode == 0:
|
|
589
|
+
data = Path(tp).read_bytes()
|
|
590
|
+
os.unlink(tp)
|
|
591
|
+
return data
|
|
592
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
593
|
+
pass
|
|
594
|
+
|
|
595
|
+
if fmt == 'word' and lo:
|
|
596
|
+
try:
|
|
597
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
598
|
+
r = subprocess.run([lo, '--headless', '--convert-to', 'pdf',
|
|
599
|
+
'--outdir', tmpdir, path],
|
|
600
|
+
capture_output=True, timeout=30)
|
|
601
|
+
if r.returncode == 0:
|
|
602
|
+
pdf = Path(tmpdir) / (Path(path).stem + '.pdf')
|
|
603
|
+
if pdf.exists():
|
|
604
|
+
ps = _convert_to_ps(str(pdf))
|
|
605
|
+
return ps if ps else pdf.read_bytes()
|
|
606
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
607
|
+
pass
|
|
608
|
+
return None
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _prepare_payload(path: str, copies: int = 1,
|
|
612
|
+
prefer_escp: bool = False,
|
|
613
|
+
prefer_pwg: bool = False) -> Tuple[bytes, str]:
|
|
614
|
+
"""
|
|
615
|
+
Prepare the print payload and return (data, description).
|
|
616
|
+
|
|
617
|
+
Args:
|
|
618
|
+
prefer_escp: When True, prefer ESC/P over JPEG/PS (use for LPD → Epson inkjets).
|
|
619
|
+
prefer_pwg: When True, generate PWG Raster for images (use for IPP → Epson inkjets).
|
|
620
|
+
"""
|
|
621
|
+
fmt = _detect_format(path)
|
|
622
|
+
raw_data = Path(path).read_bytes()
|
|
623
|
+
_log.debug("Detected format '%s' for %s", fmt, path)
|
|
624
|
+
|
|
625
|
+
if fmt == 'ps':
|
|
626
|
+
return (UEL + f'@PJL SET COPIES={copies}\r\n'.encode() + UEL + raw_data
|
|
627
|
+
if copies > 1 else raw_data, 'PostScript (as-is)')
|
|
628
|
+
|
|
629
|
+
if fmt == 'pcl':
|
|
630
|
+
return raw_data, 'PCL (as-is)'
|
|
631
|
+
|
|
632
|
+
if fmt == 'text':
|
|
633
|
+
text = raw_data.decode('utf-8', errors='replace')
|
|
634
|
+
if prefer_escp:
|
|
635
|
+
data = _text_to_escp(text, copies=copies)
|
|
636
|
+
return data, 'ESC/P (Epson native text mode)'
|
|
637
|
+
jpeg = _text_to_jpeg(text, copies=copies)
|
|
638
|
+
if jpeg:
|
|
639
|
+
return jpeg, 'JPEG via Pillow (300 dpi)'
|
|
640
|
+
data = _text_to_escp(text, copies=copies)
|
|
641
|
+
return data, 'ESC/P (Epson native text mode)'
|
|
642
|
+
|
|
643
|
+
if fmt in ('pdf', 'image', 'word'):
|
|
644
|
+
# 1. PWG Raster (AirPrint/Mopria native) for IPP to Epson inkjets
|
|
645
|
+
if prefer_pwg:
|
|
646
|
+
try:
|
|
647
|
+
from PIL import Image as _PIL # type: ignore
|
|
648
|
+
img = _PIL.open(path) if fmt == 'image' else None
|
|
649
|
+
if img is None:
|
|
650
|
+
raise ValueError("Not a direct image — needs conversion")
|
|
651
|
+
pwg = _image_to_pwg_raster(img, dpi=150, color=True)
|
|
652
|
+
if pwg:
|
|
653
|
+
return pwg, 'PWG Raster srgb_8 @ 150 DPI (AirPrint/Mopria)'
|
|
654
|
+
except Exception as exc:
|
|
655
|
+
_log.debug("PWG Raster conversion failed: %s", exc)
|
|
656
|
+
|
|
657
|
+
# 2. Try Ghostscript/LibreOffice → PostScript
|
|
658
|
+
ps = _convert_to_ps(path)
|
|
659
|
+
if ps:
|
|
660
|
+
return ps, 'PostScript (converted)'
|
|
661
|
+
|
|
662
|
+
# 3. ESC/P bitmap via Pillow — images only (LPD passthrough, NOT for inkjet IPP)
|
|
663
|
+
if prefer_escp and fmt == 'image':
|
|
664
|
+
try:
|
|
665
|
+
from PIL import Image as _PIL # type: ignore
|
|
666
|
+
img = _PIL.open(path)
|
|
667
|
+
data = _image_to_escp_bitmap(img)
|
|
668
|
+
return data, 'ESC/P bitmap via Pillow (LPD passthrough)'
|
|
669
|
+
except (ImportError, Exception):
|
|
670
|
+
pass
|
|
671
|
+
|
|
672
|
+
# 4. For PDF/Word: send raw bytes (some printers parse natively)
|
|
673
|
+
if fmt in ('pdf', 'word'):
|
|
674
|
+
return raw_data, f'{fmt.upper()} (raw bytes — use OS driver for reliable printing)'
|
|
675
|
+
|
|
676
|
+
# 5. Raw image bytes
|
|
677
|
+
_log.warning("No conversion available for '%s'; sending raw bytes", fmt)
|
|
678
|
+
return raw_data, f'raw {fmt} bytes (no conversion tools found)'
|
|
679
|
+
|
|
680
|
+
return raw_data, 'raw binary'
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
# ── Protocol senders ──────────────────────────────────────────────────────────
|
|
684
|
+
|
|
685
|
+
def send_raw(host: str, port: int, data: bytes, timeout: float = 15.0) -> PrintJobResult:
|
|
686
|
+
"""Send data via RAW/JetDirect (TCP 9100) — HP/PCL printers."""
|
|
687
|
+
t0 = time.time()
|
|
688
|
+
try:
|
|
689
|
+
with socket.create_connection((host, port), timeout=timeout) as s:
|
|
690
|
+
s.settimeout(timeout)
|
|
691
|
+
sent = 0
|
|
692
|
+
while sent < len(data):
|
|
693
|
+
n = s.send(data[sent:sent + 4096])
|
|
694
|
+
if n == 0:
|
|
695
|
+
raise OSError("Connection closed by remote")
|
|
696
|
+
sent += n
|
|
697
|
+
return PrintJobResult(
|
|
698
|
+
success=True, protocol='raw', host=host, port=port,
|
|
699
|
+
file_path='', file_size=len(data),
|
|
700
|
+
elapsed_ms=(time.time() - t0) * 1000,
|
|
701
|
+
message=f"Sent {len(data)} bytes via RAW/JetDirect",
|
|
702
|
+
)
|
|
703
|
+
except OSError as exc:
|
|
704
|
+
hint = ''
|
|
705
|
+
err = str(exc)
|
|
706
|
+
if 'refused' in err.lower():
|
|
707
|
+
hint = 'Port 9100 closed — printer may not support RAW/JetDirect'
|
|
708
|
+
elif 'timed out' in err.lower():
|
|
709
|
+
hint = 'Connection timed out — printer unreachable or firewalled'
|
|
710
|
+
return PrintJobResult(
|
|
711
|
+
success=False, protocol='raw', host=host, port=port,
|
|
712
|
+
file_path='', file_size=0,
|
|
713
|
+
elapsed_ms=(time.time() - t0) * 1000,
|
|
714
|
+
error=err, hint=hint,
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _make_tls_ctx() -> ssl.SSLContext:
|
|
719
|
+
ctx = ssl.create_default_context()
|
|
720
|
+
ctx.check_hostname = False
|
|
721
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
722
|
+
return ctx
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def send_ipp(host: str, port: int, data: bytes,
|
|
726
|
+
job_name: str = 'PrinterXPL-Forge-Job',
|
|
727
|
+
doc_format: str = '',
|
|
728
|
+
use_tls: bool = False,
|
|
729
|
+
timeout: float = 30.0) -> PrintJobResult:
|
|
730
|
+
"""
|
|
731
|
+
Send data via IPP (HTTP) or IPPS (HTTPS).
|
|
732
|
+
|
|
733
|
+
Auto-upgrades to TLS when:
|
|
734
|
+
- use_tls=True (explicit)
|
|
735
|
+
- plain TCP returns HTTP 426
|
|
736
|
+
- plain TCP connection is reset (Epson firmware behaviour)
|
|
737
|
+
"""
|
|
738
|
+
t0 = time.time()
|
|
739
|
+
mime = doc_format or _detect_mime(data)
|
|
740
|
+
|
|
741
|
+
def _s(name: str, value: str, tag: int = 0x44) -> bytes:
|
|
742
|
+
"""Encode one IPP attribute per RFC 8011 §3.1.5 (tag + name-len + name + val-len + val)."""
|
|
743
|
+
nb = name.encode(); vb = value.encode()
|
|
744
|
+
return (bytes([tag]) + struct.pack('>H', len(nb)) + nb +
|
|
745
|
+
struct.pack('>H', len(vb)) + vb)
|
|
746
|
+
|
|
747
|
+
def _si(name: str, value: int, tag: int = 0x21) -> bytes:
|
|
748
|
+
"""Encode one IPP integer attribute."""
|
|
749
|
+
nb = name.encode()
|
|
750
|
+
return (bytes([tag]) + struct.pack('>H', len(nb)) + nb +
|
|
751
|
+
struct.pack('>Hi', 4, value))
|
|
752
|
+
|
|
753
|
+
# Epson firmware requires ipp:// URI even over TLS connections
|
|
754
|
+
printer_uri = f'ipp://{host}:{port}/ipp/print'
|
|
755
|
+
ipp_attrs = b'\x01'
|
|
756
|
+
ipp_attrs += _s('attributes-charset', 'utf-8', 0x47)
|
|
757
|
+
ipp_attrs += _s('attributes-natural-language', 'en', 0x48)
|
|
758
|
+
ipp_attrs += _s('printer-uri', printer_uri, 0x45)
|
|
759
|
+
ipp_attrs += _s('requesting-user-name', 'PrinterXPL-Forge', 0x42)
|
|
760
|
+
ipp_attrs += _s('job-name', job_name, 0x42)
|
|
761
|
+
ipp_attrs += _s('document-format', mime, 0x49)
|
|
762
|
+
ipp_attrs += b'\x02'
|
|
763
|
+
ipp_attrs += _s('media', 'iso_a4_210x297mm', 0x44)
|
|
764
|
+
ipp_attrs += _si('copies', 1)
|
|
765
|
+
ipp_attrs += _si('print-quality', 4)
|
|
766
|
+
ipp_attrs += b'\x03'
|
|
767
|
+
|
|
768
|
+
ipp_req = struct.pack('>BBHI', 1, 1, 0x0002, 1) + ipp_attrs + data
|
|
769
|
+
|
|
770
|
+
http_req = (
|
|
771
|
+
f'POST /ipp/print HTTP/1.1\r\n'
|
|
772
|
+
f'Host: {host}:{port}\r\n'
|
|
773
|
+
f'Content-Type: application/ipp\r\n'
|
|
774
|
+
f'Content-Length: {len(ipp_req)}\r\n'
|
|
775
|
+
f'Connection: close\r\n\r\n'
|
|
776
|
+
).encode() + ipp_req
|
|
777
|
+
|
|
778
|
+
def _send(sock: socket.socket) -> bytes:
|
|
779
|
+
sock.settimeout(timeout)
|
|
780
|
+
sock.sendall(http_req)
|
|
781
|
+
resp = b''
|
|
782
|
+
sock.settimeout(8)
|
|
783
|
+
try:
|
|
784
|
+
while True:
|
|
785
|
+
c = sock.recv(8192)
|
|
786
|
+
if not c:
|
|
787
|
+
break
|
|
788
|
+
resp += c
|
|
789
|
+
except socket.timeout:
|
|
790
|
+
pass
|
|
791
|
+
return resp
|
|
792
|
+
|
|
793
|
+
resp = b''
|
|
794
|
+
used_tls = use_tls
|
|
795
|
+
try:
|
|
796
|
+
raw = socket.create_connection((host, port), timeout=timeout)
|
|
797
|
+
if use_tls:
|
|
798
|
+
conn = _make_tls_ctx().wrap_socket(raw, server_hostname=host)
|
|
799
|
+
else:
|
|
800
|
+
conn = raw
|
|
801
|
+
with conn:
|
|
802
|
+
resp = _send(conn)
|
|
803
|
+
|
|
804
|
+
# Auto-TLS upgrade: 426 or empty response from reset
|
|
805
|
+
needs_tls = (resp.startswith(b'HTTP/1.1 426') or
|
|
806
|
+
(not resp and not use_tls))
|
|
807
|
+
if needs_tls and not use_tls:
|
|
808
|
+
_log.info("Plain TCP refused — retrying with TLS (IPPS)")
|
|
809
|
+
raw2 = socket.create_connection((host, port), timeout=timeout)
|
|
810
|
+
with _make_tls_ctx().wrap_socket(raw2, server_hostname=host) as tlsc:
|
|
811
|
+
resp = _send(tlsc)
|
|
812
|
+
used_tls = True
|
|
813
|
+
|
|
814
|
+
except ConnectionResetError:
|
|
815
|
+
# Epson forcibly resets non-TLS connections — upgrade to TLS
|
|
816
|
+
if not use_tls:
|
|
817
|
+
_log.info("Connection reset — retrying with TLS (IPPS)")
|
|
818
|
+
try:
|
|
819
|
+
raw2 = socket.create_connection((host, port), timeout=timeout)
|
|
820
|
+
with _make_tls_ctx().wrap_socket(raw2, server_hostname=host) as tlsc:
|
|
821
|
+
resp = _send(tlsc)
|
|
822
|
+
used_tls = True
|
|
823
|
+
except OSError as exc2:
|
|
824
|
+
return PrintJobResult(
|
|
825
|
+
success=False, protocol='ipp', host=host, port=port,
|
|
826
|
+
file_path='', file_size=0,
|
|
827
|
+
elapsed_ms=(time.time() - t0) * 1000,
|
|
828
|
+
error=str(exc2), hint='IPPS upgrade also failed',
|
|
829
|
+
)
|
|
830
|
+
else:
|
|
831
|
+
return PrintJobResult(
|
|
832
|
+
success=False, protocol='ipp', host=host, port=port,
|
|
833
|
+
file_path='', file_size=0,
|
|
834
|
+
elapsed_ms=(time.time() - t0) * 1000,
|
|
835
|
+
error='Connection reset by printer',
|
|
836
|
+
)
|
|
837
|
+
except OSError as exc:
|
|
838
|
+
err = str(exc)
|
|
839
|
+
hint = ''
|
|
840
|
+
if 'refused' in err.lower():
|
|
841
|
+
hint = 'Port 631 closed — printer does not support IPP'
|
|
842
|
+
elif 'timed out' in err.lower():
|
|
843
|
+
hint = 'IPP connection timed out — printer may be firewalled'
|
|
844
|
+
return PrintJobResult(
|
|
845
|
+
success=False, protocol='ipp', host=host, port=port,
|
|
846
|
+
file_path='', file_size=0,
|
|
847
|
+
elapsed_ms=(time.time() - t0) * 1000,
|
|
848
|
+
error=err, hint=hint,
|
|
849
|
+
)
|
|
850
|
+
|
|
851
|
+
# Parse IPP response
|
|
852
|
+
ipp_status = 0xFFFF
|
|
853
|
+
job_id = None
|
|
854
|
+
sep = resp.find(b'\r\n\r\n')
|
|
855
|
+
ipp_resp = resp[sep + 4:] if sep != -1 else b''
|
|
856
|
+
if len(ipp_resp) >= 4:
|
|
857
|
+
ipp_status = struct.unpack('>H', ipp_resp[2:4])[0]
|
|
858
|
+
success = (ipp_status == _IPP_OK)
|
|
859
|
+
else:
|
|
860
|
+
success = b'200' in resp[:100]
|
|
861
|
+
|
|
862
|
+
elapsed = (time.time() - t0) * 1000
|
|
863
|
+
proto_label = 'ipps' if used_tls else 'ipp'
|
|
864
|
+
|
|
865
|
+
if success:
|
|
866
|
+
return PrintJobResult(
|
|
867
|
+
success=True, protocol=proto_label, host=host, port=port,
|
|
868
|
+
file_path='', file_size=len(data), job_id=job_id,
|
|
869
|
+
elapsed_ms=elapsed,
|
|
870
|
+
message=f"Print-Job accepted ({len(data)} bytes, format={mime})",
|
|
871
|
+
)
|
|
872
|
+
|
|
873
|
+
# Map IPP error to actionable hint
|
|
874
|
+
hint = _classify_ipp_error(ipp_status, mime)
|
|
875
|
+
err_desc = _IPP_ERRORS.get(ipp_status, f'IPP error 0x{ipp_status:04X}')
|
|
876
|
+
return PrintJobResult(
|
|
877
|
+
success=False, protocol=proto_label, host=host, port=port,
|
|
878
|
+
file_path='', file_size=len(data),
|
|
879
|
+
elapsed_ms=elapsed,
|
|
880
|
+
error=err_desc,
|
|
881
|
+
hint=hint,
|
|
882
|
+
)
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def _classify_ipp_error(status: int, mime: str) -> str:
|
|
886
|
+
"""Return an operator-friendly hint for a given IPP error status."""
|
|
887
|
+
install_hint = (
|
|
888
|
+
"Como alternativa, instale a impressora no SO com --install-printer "
|
|
889
|
+
"e tente imprimir normalmente pelo driver do sistema operacional."
|
|
890
|
+
)
|
|
891
|
+
if status == _IPP_FORMAT_NOT_SUPPORTED:
|
|
892
|
+
return (
|
|
893
|
+
f"A impressora não aceita o formato '{mime}' via IPP. "
|
|
894
|
+
f"Tente --send-proto lpd (LPD/ESC-P) ou: {install_hint}"
|
|
895
|
+
)
|
|
896
|
+
if status == 0x0400: # client-error-bad-request
|
|
897
|
+
return (
|
|
898
|
+
"Requisição IPP rejeitada pela impressora (Bad Request). "
|
|
899
|
+
"A impressora pode estar ocupada ou o formato de dados é incompatível. "
|
|
900
|
+
f"{install_hint}"
|
|
901
|
+
)
|
|
902
|
+
if status == _IPP_STATUS_BUSY:
|
|
903
|
+
return (
|
|
904
|
+
"A impressora está ocupada processando outro job. "
|
|
905
|
+
"Aguarde o LED do painel frontal apagar e tente novamente. "
|
|
906
|
+
f"{install_hint}"
|
|
907
|
+
)
|
|
908
|
+
if status in (_IPP_NOT_AUTHORIZED, 0x0402):
|
|
909
|
+
return (
|
|
910
|
+
"A impressora exige autenticação para impressão via IPP (configuração reforçada). "
|
|
911
|
+
f"{install_hint}"
|
|
912
|
+
)
|
|
913
|
+
if status == _IPP_DEVICE_ERROR:
|
|
914
|
+
return "Erro de hardware na impressora — verifique papel, tinta e tampa."
|
|
915
|
+
if status == _IPP_OP_NOT_SUPPORTED:
|
|
916
|
+
return (
|
|
917
|
+
"A impressora não suporta operação IPP Print-Job via rede. "
|
|
918
|
+
f"{install_hint}"
|
|
919
|
+
)
|
|
920
|
+
return (
|
|
921
|
+
f"Verifique o status da impressora (IPP 0x{status:04X}). "
|
|
922
|
+
f"{install_hint}"
|
|
923
|
+
)
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def send_lpd(host: str, port: int, data: bytes,
|
|
927
|
+
queue: str = 'lp',
|
|
928
|
+
job_name: str = 'PrinterXPL-Forge',
|
|
929
|
+
timeout: float = 20.0) -> PrintJobResult:
|
|
930
|
+
"""
|
|
931
|
+
Send data via LPD (RFC 1179) on TCP 515.
|
|
932
|
+
Uses passthrough ('l') control-file command so ESC/P data reaches the print engine.
|
|
933
|
+
"""
|
|
934
|
+
t0 = time.time()
|
|
935
|
+
try:
|
|
936
|
+
with socket.create_connection((host, port), timeout=timeout) as s:
|
|
937
|
+
s.settimeout(timeout)
|
|
938
|
+
|
|
939
|
+
s.sendall(b'\x02' + queue.encode() + b'\n')
|
|
940
|
+
ack = s.recv(1)
|
|
941
|
+
if ack != b'\x00':
|
|
942
|
+
raise OSError(f"LPD NAK on receive-job: {ack!r}")
|
|
943
|
+
|
|
944
|
+
ctrl = (
|
|
945
|
+
f'H{host}\nP{job_name}\nJ{job_name}\n'
|
|
946
|
+
f'ldfA001{host}\n' # 'l' = passthrough, no filter translation
|
|
947
|
+
f'N{job_name}\n'
|
|
948
|
+
).encode()
|
|
949
|
+
ctrl_name = f'cfA001{host[:15]}'
|
|
950
|
+
s.sendall(f'\x02{len(ctrl)} {ctrl_name}\n'.encode())
|
|
951
|
+
if s.recv(1) != b'\x00':
|
|
952
|
+
raise OSError("LPD NAK on control-file header")
|
|
953
|
+
s.sendall(ctrl + b'\x00')
|
|
954
|
+
s.recv(1) # ACK for control data
|
|
955
|
+
|
|
956
|
+
data_name = f'dfA001{host[:15]}'
|
|
957
|
+
s.sendall(f'\x03{len(data)} {data_name}\n'.encode())
|
|
958
|
+
if s.recv(1) != b'\x00':
|
|
959
|
+
raise OSError("LPD NAK on data-file header")
|
|
960
|
+
s.sendall(data + b'\x00')
|
|
961
|
+
s.settimeout(8)
|
|
962
|
+
try:
|
|
963
|
+
s.recv(1) # final ACK (may timeout on some printers — OK)
|
|
964
|
+
except socket.timeout:
|
|
965
|
+
pass
|
|
966
|
+
|
|
967
|
+
return PrintJobResult(
|
|
968
|
+
success=True, protocol='lpd', host=host, port=port,
|
|
969
|
+
file_path='', file_size=len(data),
|
|
970
|
+
elapsed_ms=(time.time() - t0) * 1000,
|
|
971
|
+
message=f"LPD job submitted on queue '{queue}' ({len(data)} bytes)",
|
|
972
|
+
)
|
|
973
|
+
except OSError as exc:
|
|
974
|
+
err = str(exc)
|
|
975
|
+
hint = ''
|
|
976
|
+
if 'NAK' in err:
|
|
977
|
+
hint = 'Printer refused the LPD job (hardened or busy). Check queue name.'
|
|
978
|
+
elif 'refused' in err.lower():
|
|
979
|
+
hint = 'Port 515 closed — printer does not support LPD'
|
|
980
|
+
elif 'timed out' in err.lower():
|
|
981
|
+
hint = 'LPD connection timed out — printer may be firewalled or busy'
|
|
982
|
+
return PrintJobResult(
|
|
983
|
+
success=False, protocol='lpd', host=host, port=port,
|
|
984
|
+
file_path='', file_size=0,
|
|
985
|
+
elapsed_ms=(time.time() - t0) * 1000,
|
|
986
|
+
error=err, hint=hint,
|
|
987
|
+
)
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
def _clear_os_print_queue(printer_name: str = '') -> None:
|
|
991
|
+
"""Clear stuck/errored/paused jobs from the OS print queue before sending.
|
|
992
|
+
|
|
993
|
+
On Windows: uses PowerShell Get-PrintJob | Remove-PrintJob.
|
|
994
|
+
On Linux/macOS: uses lprm -a or cancel -a.
|
|
995
|
+
|
|
996
|
+
Args:
|
|
997
|
+
printer_name: Target printer name. Empty → clears all printers' queues.
|
|
998
|
+
"""
|
|
999
|
+
import platform
|
|
1000
|
+
os_name = platform.system()
|
|
1001
|
+
try:
|
|
1002
|
+
if os_name == 'Windows':
|
|
1003
|
+
if printer_name:
|
|
1004
|
+
pname_esc = printer_name.replace('"', '\\"')
|
|
1005
|
+
ps_cmd = (
|
|
1006
|
+
f'Get-PrintJob -PrinterName "{pname_esc}" '
|
|
1007
|
+
f'| Remove-PrintJob'
|
|
1008
|
+
)
|
|
1009
|
+
else:
|
|
1010
|
+
ps_cmd = (
|
|
1011
|
+
'Get-Printer | ForEach-Object { '
|
|
1012
|
+
' try { Get-PrintJob -PrinterName $_.Name '
|
|
1013
|
+
' | Remove-PrintJob } catch {} '
|
|
1014
|
+
'}'
|
|
1015
|
+
)
|
|
1016
|
+
subprocess.run(
|
|
1017
|
+
['powershell', '-NoProfile', '-NonInteractive',
|
|
1018
|
+
'-ExecutionPolicy', 'Bypass', '-Command', ps_cmd],
|
|
1019
|
+
capture_output=True, timeout=15,
|
|
1020
|
+
)
|
|
1021
|
+
else:
|
|
1022
|
+
if printer_name:
|
|
1023
|
+
subprocess.run(['cancel', '-a', '-U', printer_name],
|
|
1024
|
+
capture_output=True, timeout=10)
|
|
1025
|
+
else:
|
|
1026
|
+
subprocess.run(['cancel', '-a'],
|
|
1027
|
+
capture_output=True, timeout=10)
|
|
1028
|
+
except Exception as exc:
|
|
1029
|
+
_log.debug("Queue clear warning (non-fatal): %s", exc)
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
def send_os_print(path: str, printer_name: str = '',
|
|
1033
|
+
timeout: float = 30.0,
|
|
1034
|
+
clear_queue: bool = True) -> PrintJobResult:
|
|
1035
|
+
"""
|
|
1036
|
+
Print a file via the locally installed OS printer (Windows/Linux/macOS).
|
|
1037
|
+
|
|
1038
|
+
Clears stuck/errored jobs from the queue before sending to avoid
|
|
1039
|
+
the printer staying offline or blocked by a previous failed job.
|
|
1040
|
+
|
|
1041
|
+
On Windows: uses PowerShell Start-Process with -Verb PrintTo.
|
|
1042
|
+
On Linux/macOS: uses lpr command.
|
|
1043
|
+
|
|
1044
|
+
Args:
|
|
1045
|
+
path: File to print.
|
|
1046
|
+
printer_name: OS printer name. Empty → system default.
|
|
1047
|
+
timeout: Wait timeout in seconds.
|
|
1048
|
+
clear_queue: If True, clears stuck jobs before printing (default: True).
|
|
1049
|
+
"""
|
|
1050
|
+
import platform
|
|
1051
|
+
t0 = time.time()
|
|
1052
|
+
p = Path(path)
|
|
1053
|
+
if not p.exists():
|
|
1054
|
+
return PrintJobResult(
|
|
1055
|
+
success=False, protocol='os', host='localhost', port=0,
|
|
1056
|
+
file_path=path, file_size=0,
|
|
1057
|
+
error=f'File not found: {path}',
|
|
1058
|
+
)
|
|
1059
|
+
|
|
1060
|
+
os_name = platform.system()
|
|
1061
|
+
|
|
1062
|
+
# Clear the print queue before submitting to avoid blocked/offline state
|
|
1063
|
+
if clear_queue:
|
|
1064
|
+
_log.info("Clearing OS print queue before sending job...")
|
|
1065
|
+
_clear_os_print_queue(printer_name)
|
|
1066
|
+
time.sleep(0.5) # brief pause so the spooler resets
|
|
1067
|
+
|
|
1068
|
+
try:
|
|
1069
|
+
if os_name == 'Windows':
|
|
1070
|
+
# Choose the best print method based on file type
|
|
1071
|
+
suffix = p.suffix.lower()
|
|
1072
|
+
img_exts = {'.jpg', '.jpeg', '.png', '.bmp', '.gif', '.tiff', '.tif', '.webp'}
|
|
1073
|
+
|
|
1074
|
+
if suffix in img_exts:
|
|
1075
|
+
# Images: use mspaint /pt which works for common image formats
|
|
1076
|
+
mspaint = shutil.which('mspaint') or 'mspaint'
|
|
1077
|
+
args_list = [mspaint, '/pt', str(p.resolve())]
|
|
1078
|
+
if printer_name:
|
|
1079
|
+
args_list.append(printer_name)
|
|
1080
|
+
r = subprocess.run(args_list, capture_output=True, timeout=timeout)
|
|
1081
|
+
elif suffix == '.txt':
|
|
1082
|
+
# Plain text: use notepad /pt
|
|
1083
|
+
notepad = shutil.which('notepad') or 'notepad'
|
|
1084
|
+
args_list = [notepad, '/pt', str(p.resolve())]
|
|
1085
|
+
if printer_name:
|
|
1086
|
+
args_list += [printer_name, printer_name, '']
|
|
1087
|
+
r = subprocess.run(args_list, capture_output=True, timeout=timeout)
|
|
1088
|
+
else:
|
|
1089
|
+
# PDF and others: try -Verb PrintTo via PowerShell
|
|
1090
|
+
pname_esc = printer_name.replace('"', '\\"')
|
|
1091
|
+
ps_cmd = (
|
|
1092
|
+
f'Start-Process -FilePath "{p.resolve()}" -Verb PrintTo '
|
|
1093
|
+
+ (f'-ArgumentList "{pname_esc}"' if printer_name else '')
|
|
1094
|
+
+ ' -Wait -ErrorAction Stop'
|
|
1095
|
+
)
|
|
1096
|
+
r = subprocess.run(
|
|
1097
|
+
['powershell', '-NoProfile', '-NonInteractive',
|
|
1098
|
+
'-ExecutionPolicy', 'Bypass', '-Command', ps_cmd],
|
|
1099
|
+
capture_output=True, timeout=timeout,
|
|
1100
|
+
)
|
|
1101
|
+
|
|
1102
|
+
success = r.returncode == 0
|
|
1103
|
+
stderr = r.stderr.decode('utf-8', errors='replace').strip()
|
|
1104
|
+
if not success:
|
|
1105
|
+
return PrintJobResult(
|
|
1106
|
+
success=False, protocol='os', host='localhost', port=0,
|
|
1107
|
+
file_path=path, file_size=p.stat().st_size,
|
|
1108
|
+
elapsed_ms=(time.time() - t0) * 1000,
|
|
1109
|
+
error=f'OS print failed (rc={r.returncode}): {stderr}',
|
|
1110
|
+
hint=('Verifique se a impressora está instalada em Configurações → '
|
|
1111
|
+
'Impressoras. Execute --install-printer para adicioná-la.'),
|
|
1112
|
+
)
|
|
1113
|
+
else:
|
|
1114
|
+
lpr = shutil.which('lpr') or shutil.which('lp')
|
|
1115
|
+
if not lpr:
|
|
1116
|
+
return PrintJobResult(
|
|
1117
|
+
success=False, protocol='os', host='localhost', port=0,
|
|
1118
|
+
file_path=path, file_size=p.stat().st_size,
|
|
1119
|
+
error='lpr/lp not found — CUPS not installed',
|
|
1120
|
+
hint='Install CUPS: sudo apt install cups (Debian/Ubuntu) '
|
|
1121
|
+
'or brew install cups (macOS)',
|
|
1122
|
+
)
|
|
1123
|
+
cmd = [lpr]
|
|
1124
|
+
if printer_name:
|
|
1125
|
+
cmd += ['-P', printer_name]
|
|
1126
|
+
cmd.append(str(p.resolve()))
|
|
1127
|
+
r = subprocess.run(cmd, capture_output=True, timeout=timeout)
|
|
1128
|
+
success = r.returncode == 0
|
|
1129
|
+
stderr = r.stderr.decode('utf-8', errors='replace').strip()
|
|
1130
|
+
if not success:
|
|
1131
|
+
return PrintJobResult(
|
|
1132
|
+
success=False, protocol='os', host='localhost', port=0,
|
|
1133
|
+
file_path=path, file_size=p.stat().st_size,
|
|
1134
|
+
elapsed_ms=(time.time() - t0) * 1000,
|
|
1135
|
+
error=f'lpr failed (rc={r.returncode}): {stderr}',
|
|
1136
|
+
hint=('Check that the printer is configured in CUPS. '
|
|
1137
|
+
'Run --install-printer to add it.'),
|
|
1138
|
+
)
|
|
1139
|
+
|
|
1140
|
+
return PrintJobResult(
|
|
1141
|
+
success=True, protocol='os', host='localhost', port=0,
|
|
1142
|
+
file_path=path, file_size=p.stat().st_size,
|
|
1143
|
+
elapsed_ms=(time.time() - t0) * 1000,
|
|
1144
|
+
message=(f'File sent to OS printer '
|
|
1145
|
+
f'"{printer_name or "default"}" via {os_name} spooler'),
|
|
1146
|
+
)
|
|
1147
|
+
except subprocess.TimeoutExpired:
|
|
1148
|
+
return PrintJobResult(
|
|
1149
|
+
success=False, protocol='os', host='localhost', port=0,
|
|
1150
|
+
file_path=path, file_size=p.stat().st_size,
|
|
1151
|
+
elapsed_ms=(time.time() - t0) * 1000,
|
|
1152
|
+
error='OS print command timed out',
|
|
1153
|
+
hint='Printer may be offline or unresponsive.',
|
|
1154
|
+
)
|
|
1155
|
+
except OSError as exc:
|
|
1156
|
+
return PrintJobResult(
|
|
1157
|
+
success=False, protocol='os', host='localhost', port=0,
|
|
1158
|
+
file_path=path, file_size=p.stat().st_size,
|
|
1159
|
+
elapsed_ms=(time.time() - t0) * 1000,
|
|
1160
|
+
error=str(exc),
|
|
1161
|
+
)
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
# ── Public API ────────────────────────────────────────────────────────────────
|
|
1165
|
+
|
|
1166
|
+
def send_print_job(
|
|
1167
|
+
host: str,
|
|
1168
|
+
path: str,
|
|
1169
|
+
protocol: str = 'auto',
|
|
1170
|
+
port: int = 0,
|
|
1171
|
+
copies: int = 1,
|
|
1172
|
+
queue: str = 'lp',
|
|
1173
|
+
timeout: float = 25.0,
|
|
1174
|
+
caps: Optional[PrinterCapabilities] = None,
|
|
1175
|
+
) -> PrintJobResult:
|
|
1176
|
+
"""
|
|
1177
|
+
High-level print-job sender with automatic protocol/format selection.
|
|
1178
|
+
|
|
1179
|
+
Args:
|
|
1180
|
+
host: Target IP or hostname.
|
|
1181
|
+
path: File to print (absolute or relative path).
|
|
1182
|
+
protocol: 'auto' (smart probe) | 'ipp' | 'lpd' | 'raw'.
|
|
1183
|
+
port: Override default port (0 = use protocol default).
|
|
1184
|
+
copies: Number of copies to print.
|
|
1185
|
+
queue: LPD queue name (default 'lp').
|
|
1186
|
+
timeout: Socket timeout in seconds.
|
|
1187
|
+
caps: Pre-computed PrinterCapabilities (skips probe if provided).
|
|
1188
|
+
|
|
1189
|
+
Returns:
|
|
1190
|
+
PrintJobResult with .success, .message, .error, .hint.
|
|
1191
|
+
"""
|
|
1192
|
+
proto = protocol.lower().strip()
|
|
1193
|
+
p = Path(path)
|
|
1194
|
+
|
|
1195
|
+
if not p.exists():
|
|
1196
|
+
return PrintJobResult(
|
|
1197
|
+
success=False, protocol=proto or 'unknown',
|
|
1198
|
+
host=host, port=port, file_path=path, file_size=0,
|
|
1199
|
+
error=f"File not found: {path}",
|
|
1200
|
+
)
|
|
1201
|
+
|
|
1202
|
+
# Probe printer if auto-selecting protocol
|
|
1203
|
+
if caps is None and (proto == 'auto' or not proto):
|
|
1204
|
+
_log.info("Probing printer %s for capabilities...", host)
|
|
1205
|
+
caps = probe_printer(host, timeout=min(timeout, 6.0))
|
|
1206
|
+
|
|
1207
|
+
# Resolve protocol
|
|
1208
|
+
if proto in ('auto', ''):
|
|
1209
|
+
if caps:
|
|
1210
|
+
proto = caps.best_protocol or 'ipp'
|
|
1211
|
+
else:
|
|
1212
|
+
proto = 'ipp'
|
|
1213
|
+
|
|
1214
|
+
# Resolve port
|
|
1215
|
+
default_ports = {'raw': 9100, 'ipp': 631, 'lpd': 515}
|
|
1216
|
+
actual_port = port or default_ports.get(proto, 9100)
|
|
1217
|
+
use_tls = bool(caps and caps.ipp_requires_tls) if proto == 'ipp' else False
|
|
1218
|
+
|
|
1219
|
+
# Prepare payload
|
|
1220
|
+
prefer_escp = (proto == 'lpd')
|
|
1221
|
+
prefer_pwg = (proto == 'ipp') # IPP to Epson inkjets → PWG Raster
|
|
1222
|
+
try:
|
|
1223
|
+
payload, fmt_desc = _prepare_payload(
|
|
1224
|
+
str(p), copies=copies,
|
|
1225
|
+
prefer_escp=prefer_escp,
|
|
1226
|
+
prefer_pwg=prefer_pwg,
|
|
1227
|
+
)
|
|
1228
|
+
except Exception as exc:
|
|
1229
|
+
return PrintJobResult(
|
|
1230
|
+
success=False, protocol=proto, host=host, port=actual_port,
|
|
1231
|
+
file_path=path, file_size=0,
|
|
1232
|
+
error=f"Payload preparation failed: {exc}",
|
|
1233
|
+
)
|
|
1234
|
+
|
|
1235
|
+
_log.info("Sending %s → %s:%d via %s (format: %s, %d bytes)",
|
|
1236
|
+
path, host, actual_port, proto.upper(), fmt_desc, len(payload))
|
|
1237
|
+
|
|
1238
|
+
# Dispatch
|
|
1239
|
+
result: PrintJobResult
|
|
1240
|
+
if proto == 'raw':
|
|
1241
|
+
result = send_raw(host, actual_port, payload, timeout=timeout)
|
|
1242
|
+
|
|
1243
|
+
elif proto == 'ipp':
|
|
1244
|
+
mime = _detect_mime(payload)
|
|
1245
|
+
result = send_ipp(
|
|
1246
|
+
host, actual_port, payload,
|
|
1247
|
+
job_name = p.name,
|
|
1248
|
+
doc_format = mime,
|
|
1249
|
+
use_tls = use_tls,
|
|
1250
|
+
timeout = timeout,
|
|
1251
|
+
)
|
|
1252
|
+
# Fallback 1: LPD (ESC/P) if available
|
|
1253
|
+
if not result.success and caps and caps.lpd_available:
|
|
1254
|
+
_log.info("IPP rejected — falling back to LPD with ESC/P")
|
|
1255
|
+
escp_payload, escp_desc = _prepare_payload(
|
|
1256
|
+
str(p), copies=copies, prefer_escp=True)
|
|
1257
|
+
lpd_result = send_lpd(host, 515, escp_payload, queue=queue,
|
|
1258
|
+
job_name=p.stem, timeout=timeout)
|
|
1259
|
+
if lpd_result.success:
|
|
1260
|
+
lpd_result.message = f"[IPP failed → LPD fallback] {lpd_result.message}"
|
|
1261
|
+
result = lpd_result
|
|
1262
|
+
|
|
1263
|
+
# Annotate failure with OS install suggestion
|
|
1264
|
+
if not result.success:
|
|
1265
|
+
_log.info("All network protocols failed for %s — OS printing may work", path)
|
|
1266
|
+
if not result.hint:
|
|
1267
|
+
result.hint = (
|
|
1268
|
+
"Não foi possível imprimir via protocolos de rede (IPP/LPD). "
|
|
1269
|
+
"Tente instalar a impressora no SO com --install-printer e "
|
|
1270
|
+
"imprimir normalmente pelo spooler do sistema operacional."
|
|
1271
|
+
)
|
|
1272
|
+
|
|
1273
|
+
elif proto == 'lpd':
|
|
1274
|
+
result = send_lpd(host, actual_port, payload, queue=queue,
|
|
1275
|
+
job_name=p.stem, timeout=timeout)
|
|
1276
|
+
if not result.success and not result.hint:
|
|
1277
|
+
result.hint = (
|
|
1278
|
+
"Falha no envio via LPD. Se a impressora estiver instalada "
|
|
1279
|
+
"localmente via driver do SO, tente imprimir normalmente. "
|
|
1280
|
+
"Caso contrário, use --install-printer."
|
|
1281
|
+
)
|
|
1282
|
+
else:
|
|
1283
|
+
return PrintJobResult(
|
|
1284
|
+
success=False, protocol=proto, host=host, port=actual_port,
|
|
1285
|
+
file_path=path, file_size=0, error=f"Unknown protocol: {proto}",
|
|
1286
|
+
)
|
|
1287
|
+
|
|
1288
|
+
result.file_path = path
|
|
1289
|
+
result.file_size = len(payload)
|
|
1290
|
+
return result
|