@crowsgear/escl-protocol-scanner 0.1.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/LICENSE +21 -0
- package/README.md +663 -0
- package/dist/client.d.ts +69 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +320 -0
- package/dist/client.js.map +1 -0
- package/dist/discovery.d.ts +71 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +269 -0
- package/dist/discovery.js.map +1 -0
- package/dist/index.d.ts +76 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +250 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +170 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +7 -0
- package/dist/types.js.map +1 -0
- package/package.json +63 -0
- package/python/base.py +57 -0
- package/python/escl_backend.py +541 -0
- package/python/escl_main.py +119 -0
- package/scripts/check-python-deps.js +185 -0
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
eSCL (AirPrint) Scanner Backend
|
|
5
|
+
HTTP-based network scanner support
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
import base64
|
|
11
|
+
import urllib.request
|
|
12
|
+
import urllib.error
|
|
13
|
+
import xml.etree.ElementTree as ET
|
|
14
|
+
from io import BytesIO
|
|
15
|
+
from base import ScannerBackend
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
from zeroconf import ServiceBrowser, ServiceListener, Zeroconf
|
|
19
|
+
from PIL import Image
|
|
20
|
+
ESCL_AVAILABLE = True
|
|
21
|
+
except ImportError as e:
|
|
22
|
+
ESCL_AVAILABLE = False
|
|
23
|
+
|
|
24
|
+
# eSCL namespaces
|
|
25
|
+
ESCL_NS = "http://schemas.hp.com/imaging/escl/2011/05/03"
|
|
26
|
+
PWG_NS = "http://www.pwg.org/schemas/2010/12/sm"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ESCLScannerListener(ServiceListener):
|
|
30
|
+
"""eSCL Scanner Discovery Listener"""
|
|
31
|
+
|
|
32
|
+
def __init__(self):
|
|
33
|
+
self.scanners = []
|
|
34
|
+
|
|
35
|
+
def add_service(self, zc: Zeroconf, type_: str, name: str) -> None:
|
|
36
|
+
"""Called when a scanner is discovered"""
|
|
37
|
+
print(f'[eSCL] Scanner detected: {name}', file=sys.stderr, flush=True)
|
|
38
|
+
|
|
39
|
+
info = zc.get_service_info(type_, name)
|
|
40
|
+
|
|
41
|
+
if info and info.addresses:
|
|
42
|
+
# Convert IPv4 address
|
|
43
|
+
host = ".".join(map(str, info.addresses[0]))
|
|
44
|
+
|
|
45
|
+
# Remove service type from mDNS name (e.g., "Canon iR-ADV C3525._uscan._tcp.local." -> "Canon iR-ADV C3525")
|
|
46
|
+
clean_name = name.split('._')[0] if '._' in name else name
|
|
47
|
+
|
|
48
|
+
scanner_info = {
|
|
49
|
+
'name': clean_name,
|
|
50
|
+
'host': host,
|
|
51
|
+
'port': info.port,
|
|
52
|
+
'type': type_
|
|
53
|
+
}
|
|
54
|
+
self.scanners.append(scanner_info)
|
|
55
|
+
print(f'[eSCL] Scanner added: {clean_name} ({host}:{info.port})', file=sys.stderr, flush=True)
|
|
56
|
+
|
|
57
|
+
def remove_service(self, zc: Zeroconf, type_: str, name: str) -> None:
|
|
58
|
+
"""Called when a scanner is removed"""
|
|
59
|
+
print(f'[eSCL] Scanner removed: {name}', file=sys.stderr, flush=True)
|
|
60
|
+
|
|
61
|
+
def update_service(self, zc: Zeroconf, type_: str, name: str) -> None:
|
|
62
|
+
"""Called when scanner information is updated (ignored)"""
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ESCLBackend(ScannerBackend):
|
|
67
|
+
"""eSCL (AirPrint) backend for network scanners"""
|
|
68
|
+
|
|
69
|
+
def __init__(self):
|
|
70
|
+
if not ESCL_AVAILABLE:
|
|
71
|
+
raise ImportError('eSCL libraries not found. Install with: pip install zeroconf pillow')
|
|
72
|
+
|
|
73
|
+
self.discovered_scanners = []
|
|
74
|
+
|
|
75
|
+
def get_capabilities(self, params):
|
|
76
|
+
"""Retrieve and parse scanner capabilities"""
|
|
77
|
+
try:
|
|
78
|
+
scanner_name = params.get('scanner')
|
|
79
|
+
|
|
80
|
+
if not scanner_name:
|
|
81
|
+
return {
|
|
82
|
+
'success': False,
|
|
83
|
+
'error': 'No scanner selected'
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
# Find selected scanner information
|
|
87
|
+
selected = None
|
|
88
|
+
for s in self.discovered_scanners:
|
|
89
|
+
if s['name'] == scanner_name:
|
|
90
|
+
selected = s
|
|
91
|
+
break
|
|
92
|
+
|
|
93
|
+
if not selected:
|
|
94
|
+
return {
|
|
95
|
+
'success': False,
|
|
96
|
+
'error': f'Scanner information not found: {scanner_name}'
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
host = selected['host']
|
|
100
|
+
port = selected['port']
|
|
101
|
+
|
|
102
|
+
# Retrieve capabilities
|
|
103
|
+
url = f"http://{host}:{port}/eSCL/ScannerCapabilities"
|
|
104
|
+
response = urllib.request.urlopen(url, timeout=5)
|
|
105
|
+
content = response.read()
|
|
106
|
+
|
|
107
|
+
root = ET.fromstring(content)
|
|
108
|
+
|
|
109
|
+
# Parse supported resolutions
|
|
110
|
+
resolutions = []
|
|
111
|
+
res_elements = root.findall('.//{%s}DiscreteResolution' % ESCL_NS)
|
|
112
|
+
for res in res_elements:
|
|
113
|
+
x_res = res.find('.//{%s}XResolution' % ESCL_NS)
|
|
114
|
+
if x_res is not None and x_res.text:
|
|
115
|
+
resolutions.append(int(x_res.text))
|
|
116
|
+
|
|
117
|
+
# Parse supported color modes
|
|
118
|
+
color_modes = []
|
|
119
|
+
mode_elements = root.findall('.//{%s}ColorMode' % ESCL_NS)
|
|
120
|
+
for mode in mode_elements:
|
|
121
|
+
if mode.text:
|
|
122
|
+
color_modes.append(mode.text)
|
|
123
|
+
|
|
124
|
+
# Check input sources (Platen, Adf support)
|
|
125
|
+
input_sources = []
|
|
126
|
+
if root.find('.//{%s}Platen' % ESCL_NS) is not None:
|
|
127
|
+
input_sources.append('Platen')
|
|
128
|
+
if root.find('.//{%s}Adf' % ESCL_NS) is not None:
|
|
129
|
+
input_sources.append('Adf')
|
|
130
|
+
|
|
131
|
+
# Remove duplicates and sort
|
|
132
|
+
resolutions = sorted(list(set(resolutions)))
|
|
133
|
+
color_modes = list(set(color_modes))
|
|
134
|
+
|
|
135
|
+
print(f'[eSCL] Capabilities parsed: resolutions={resolutions}, modes={color_modes}, sources={input_sources}', file=sys.stderr, flush=True)
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
'success': True,
|
|
139
|
+
'capabilities': {
|
|
140
|
+
'resolutions': resolutions,
|
|
141
|
+
'colorModes': color_modes,
|
|
142
|
+
'inputSources': input_sources
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
except Exception as e:
|
|
147
|
+
print(f'[eSCL] Capabilities retrieval error: {str(e)}', file=sys.stderr, flush=True)
|
|
148
|
+
return {
|
|
149
|
+
'success': False,
|
|
150
|
+
'error': f'Capabilities retrieval failed: {str(e)}'
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
def list_scanners(self):
|
|
154
|
+
"""Discover eSCL scanners on the network"""
|
|
155
|
+
try:
|
|
156
|
+
print('[eSCL] Scanner discovery started...', file=sys.stderr, flush=True)
|
|
157
|
+
|
|
158
|
+
zeroconf = Zeroconf()
|
|
159
|
+
listener = ESCLScannerListener()
|
|
160
|
+
|
|
161
|
+
# eSCL service types
|
|
162
|
+
services = [
|
|
163
|
+
"_uscan._tcp.local.",
|
|
164
|
+
"_uscans._tcp.local."
|
|
165
|
+
]
|
|
166
|
+
|
|
167
|
+
browsers = [ServiceBrowser(zeroconf, service, listener) for service in services]
|
|
168
|
+
|
|
169
|
+
# Wait 5 seconds for discovery
|
|
170
|
+
time.sleep(5)
|
|
171
|
+
|
|
172
|
+
zeroconf.close()
|
|
173
|
+
|
|
174
|
+
print(f'[eSCL] Discovery complete: {len(listener.scanners)} scanner(s) found', file=sys.stderr, flush=True)
|
|
175
|
+
|
|
176
|
+
# Store discovery results
|
|
177
|
+
self.discovered_scanners = listener.scanners
|
|
178
|
+
|
|
179
|
+
if listener.scanners:
|
|
180
|
+
# Return scanner objects (including name, host, port)
|
|
181
|
+
return {
|
|
182
|
+
'success': True,
|
|
183
|
+
'scanners': listener.scanners,
|
|
184
|
+
'backend': 'eSCL'
|
|
185
|
+
}
|
|
186
|
+
else:
|
|
187
|
+
return {
|
|
188
|
+
'success': True,
|
|
189
|
+
'scanners': [],
|
|
190
|
+
'backend': 'eSCL'
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
except Exception as e:
|
|
194
|
+
print(f'[eSCL] Error: {str(e)}', file=sys.stderr, flush=True)
|
|
195
|
+
return {
|
|
196
|
+
'success': False,
|
|
197
|
+
'error': f'Scanner discovery failed: {str(e)}'
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
def scan(self, params):
|
|
201
|
+
"""Execute scan via eSCL protocol"""
|
|
202
|
+
try:
|
|
203
|
+
scanner_name = params.get('scanner')
|
|
204
|
+
dpi = params.get('dpi', 300)
|
|
205
|
+
mode = params.get('mode', 'gray')
|
|
206
|
+
source = params.get('source', 'Platen')
|
|
207
|
+
|
|
208
|
+
if not scanner_name:
|
|
209
|
+
return {
|
|
210
|
+
'success': False,
|
|
211
|
+
'error': 'No scanner selected'
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
# Find selected scanner information
|
|
215
|
+
selected = None
|
|
216
|
+
for s in self.discovered_scanners:
|
|
217
|
+
if s['name'] == scanner_name:
|
|
218
|
+
selected = s
|
|
219
|
+
break
|
|
220
|
+
|
|
221
|
+
if not selected:
|
|
222
|
+
return {
|
|
223
|
+
'success': False,
|
|
224
|
+
'error': f'Scanner information not found: {scanner_name}'
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
host = selected['host']
|
|
228
|
+
port = selected['port']
|
|
229
|
+
|
|
230
|
+
print(f'[eSCL] Scan started: {host}:{port}', file=sys.stderr, flush=True)
|
|
231
|
+
|
|
232
|
+
# 1. Check scanner status
|
|
233
|
+
if not self._check_scanner_status(host, port):
|
|
234
|
+
return {
|
|
235
|
+
'success': False,
|
|
236
|
+
'error': 'Scanner is not ready'
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
# 1.5. Check scanner capabilities (verify supported InputSource)
|
|
240
|
+
supported_sources = self._get_scanner_capabilities(host, port)
|
|
241
|
+
if supported_sources:
|
|
242
|
+
print(f'[eSCL] Requested source: {source}, supported: {supported_sources}', file=sys.stderr, flush=True)
|
|
243
|
+
|
|
244
|
+
# 2. Create scan job (retry if 409 error occurs)
|
|
245
|
+
job_url = self._create_scan_job(host, port, dpi, mode, source)
|
|
246
|
+
|
|
247
|
+
# If 409 Conflict occurs, cleanup existing job and retry
|
|
248
|
+
if not job_url:
|
|
249
|
+
print('[eSCL] Possible 409 error - attempting to cleanup existing jobs', file=sys.stderr, flush=True)
|
|
250
|
+
# TODO: Logic needed to retrieve and delete existing jobs
|
|
251
|
+
return {
|
|
252
|
+
'success': False,
|
|
253
|
+
'error': 'Failed to create scan job - please restart the scanner'
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if not job_url:
|
|
257
|
+
return {
|
|
258
|
+
'success': False,
|
|
259
|
+
'error': 'Failed to create scan job'
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
# 3. Wait for scan to complete (5 seconds)
|
|
263
|
+
print('[eSCL] Scan in progress... (waiting 5 seconds)', file=sys.stderr, flush=True)
|
|
264
|
+
time.sleep(5)
|
|
265
|
+
|
|
266
|
+
# 4. Download scan results (ADF can have multiple pages)
|
|
267
|
+
import os
|
|
268
|
+
from datetime import datetime
|
|
269
|
+
|
|
270
|
+
# Create save directory (project_root/scans)
|
|
271
|
+
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../..'))
|
|
272
|
+
save_dir = os.path.join(project_root, 'scans')
|
|
273
|
+
os.makedirs(save_dir, exist_ok=True)
|
|
274
|
+
|
|
275
|
+
# Common timestamp (group pages from same scan session)
|
|
276
|
+
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
277
|
+
|
|
278
|
+
scanned_images = []
|
|
279
|
+
saved_paths = []
|
|
280
|
+
page_num = 1
|
|
281
|
+
|
|
282
|
+
# Determine if ADF (automatic feeder)
|
|
283
|
+
is_adf = (source == 'Feeder')
|
|
284
|
+
|
|
285
|
+
# 5. Call NextDocument repeatedly (until 404 for ADF)
|
|
286
|
+
while True:
|
|
287
|
+
print(f'[eSCL] Attempting to download page {page_num}...', file=sys.stderr, flush=True)
|
|
288
|
+
image_data = self._download_scan_result(job_url)
|
|
289
|
+
|
|
290
|
+
if not image_data:
|
|
291
|
+
# Error if first page not received
|
|
292
|
+
if page_num == 1:
|
|
293
|
+
self._delete_scan_job(job_url)
|
|
294
|
+
return {
|
|
295
|
+
'success': False,
|
|
296
|
+
'error': 'Cannot retrieve scan results. Please verify document is loaded in scanner.'
|
|
297
|
+
}
|
|
298
|
+
else:
|
|
299
|
+
# 404 after second page is normal completion
|
|
300
|
+
print(f'[eSCL] Scan complete: {page_num - 1} total pages', file=sys.stderr, flush=True)
|
|
301
|
+
break
|
|
302
|
+
|
|
303
|
+
# 6. Save image and Base64 encoding
|
|
304
|
+
try:
|
|
305
|
+
# Generate filename (with page number)
|
|
306
|
+
if is_adf:
|
|
307
|
+
filename = f'scan_{timestamp}_page{page_num}.jpg'
|
|
308
|
+
else:
|
|
309
|
+
filename = f'scan_{timestamp}.jpg'
|
|
310
|
+
|
|
311
|
+
filepath = os.path.join(save_dir, filename)
|
|
312
|
+
|
|
313
|
+
# Load image and handle rotation
|
|
314
|
+
img = Image.open(BytesIO(image_data))
|
|
315
|
+
print(f'[eSCL] Page {page_num} original size: {img.size[0]}x{img.size[1]}', file=sys.stderr, flush=True)
|
|
316
|
+
|
|
317
|
+
# Rotate 90 degrees counter-clockwise
|
|
318
|
+
img = img.transpose(Image.ROTATE_90)
|
|
319
|
+
print(f'[eSCL] Page {page_num} size after rotation: {img.size[0]}x{img.size[1]}', file=sys.stderr, flush=True)
|
|
320
|
+
|
|
321
|
+
# Save rotated image as JPEG
|
|
322
|
+
img.save(filepath, format='JPEG', quality=95)
|
|
323
|
+
print(f'[eSCL] File saved: {filepath}', file=sys.stderr, flush=True)
|
|
324
|
+
|
|
325
|
+
# Convert to PNG and Base64 encode (for frontend display)
|
|
326
|
+
buffer = BytesIO()
|
|
327
|
+
img.save(buffer, format='PNG')
|
|
328
|
+
encoded = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
|
329
|
+
|
|
330
|
+
scanned_images.append(encoded)
|
|
331
|
+
saved_paths.append(filepath)
|
|
332
|
+
|
|
333
|
+
except Exception as img_error:
|
|
334
|
+
print(f'[eSCL] Page {page_num} image processing error: {img_error}', file=sys.stderr, flush=True)
|
|
335
|
+
# Continue even if processing fails
|
|
336
|
+
|
|
337
|
+
# Platen only has 1 page, ADF continues to attempt
|
|
338
|
+
if not is_adf:
|
|
339
|
+
break
|
|
340
|
+
|
|
341
|
+
page_num += 1
|
|
342
|
+
|
|
343
|
+
# 7. Delete scan job
|
|
344
|
+
self._delete_scan_job(job_url)
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
'success': True,
|
|
348
|
+
'images': scanned_images,
|
|
349
|
+
'count': len(scanned_images),
|
|
350
|
+
'backend': 'eSCL',
|
|
351
|
+
'saved_paths': saved_paths
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
except Exception as e:
|
|
355
|
+
print(f'[eSCL] Scan error: {str(e)}', file=sys.stderr, flush=True)
|
|
356
|
+
return {
|
|
357
|
+
'success': False,
|
|
358
|
+
'error': f'Scan failed: {str(e)}'
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
def _check_scanner_status(self, host, port):
|
|
362
|
+
"""Check scanner status"""
|
|
363
|
+
try:
|
|
364
|
+
url = f"http://{host}:{port}/eSCL/ScannerStatus"
|
|
365
|
+
response = urllib.request.urlopen(url, timeout=5)
|
|
366
|
+
content = response.read()
|
|
367
|
+
|
|
368
|
+
root = ET.fromstring(content)
|
|
369
|
+
state = root.find('.//{%s}State' % PWG_NS)
|
|
370
|
+
|
|
371
|
+
if state is not None and state.text == 'Idle':
|
|
372
|
+
print(f'[eSCL] Scanner ready', file=sys.stderr, flush=True)
|
|
373
|
+
return True
|
|
374
|
+
|
|
375
|
+
return False
|
|
376
|
+
|
|
377
|
+
except Exception as e:
|
|
378
|
+
print(f'[eSCL] Status check failed: {e}', file=sys.stderr, flush=True)
|
|
379
|
+
return False
|
|
380
|
+
|
|
381
|
+
def _get_scanner_capabilities(self, host, port):
|
|
382
|
+
"""Retrieve scanner capabilities (verify supported InputSource)"""
|
|
383
|
+
try:
|
|
384
|
+
url = f"http://{host}:{port}/eSCL/ScannerCapabilities"
|
|
385
|
+
response = urllib.request.urlopen(url, timeout=5)
|
|
386
|
+
content = response.read()
|
|
387
|
+
|
|
388
|
+
print(f'[eSCL] Capabilities XML:\n{content.decode("utf-8")}', file=sys.stderr, flush=True)
|
|
389
|
+
|
|
390
|
+
root = ET.fromstring(content)
|
|
391
|
+
|
|
392
|
+
# Find InputSource
|
|
393
|
+
sources = root.findall('.//{%s}InputSource' % PWG_NS)
|
|
394
|
+
if sources:
|
|
395
|
+
source_list = [s.text for s in sources if s.text]
|
|
396
|
+
print(f'[eSCL] Supported InputSource: {source_list}', file=sys.stderr, flush=True)
|
|
397
|
+
return source_list
|
|
398
|
+
|
|
399
|
+
return []
|
|
400
|
+
|
|
401
|
+
except Exception as e:
|
|
402
|
+
print(f'[eSCL] Capabilities retrieval failed: {e}', file=sys.stderr, flush=True)
|
|
403
|
+
return []
|
|
404
|
+
|
|
405
|
+
def _create_scan_job(self, host, port, resolution, color_mode, input_source):
|
|
406
|
+
"""Create scan job"""
|
|
407
|
+
try:
|
|
408
|
+
url = f"http://{host}:{port}/eSCL/ScanJobs"
|
|
409
|
+
|
|
410
|
+
# Color mode mapping
|
|
411
|
+
mode_map = {
|
|
412
|
+
'gray': 'Grayscale8',
|
|
413
|
+
'bw': 'BlackAndWhite1',
|
|
414
|
+
'color': 'RGB24'
|
|
415
|
+
}
|
|
416
|
+
escl_mode = mode_map.get(color_mode, 'Grayscale8')
|
|
417
|
+
|
|
418
|
+
# eSCL scan settings XML (same as test code)
|
|
419
|
+
scan_settings = f'''<?xml version="1.0" encoding="UTF-8"?>
|
|
420
|
+
<scan:ScanSettings xmlns:scan="{ESCL_NS}" xmlns:pwg="{PWG_NS}">
|
|
421
|
+
<pwg:Version>2.0</pwg:Version>
|
|
422
|
+
<scan:Intent>Document</scan:Intent>
|
|
423
|
+
<pwg:ScanRegions>
|
|
424
|
+
<pwg:ScanRegion>
|
|
425
|
+
<pwg:ContentRegionUnits>escl:ThreeHundredthsOfInches</pwg:ContentRegionUnits>
|
|
426
|
+
<pwg:XOffset>0</pwg:XOffset>
|
|
427
|
+
<pwg:YOffset>0</pwg:YOffset>
|
|
428
|
+
<pwg:Width>3508</pwg:Width>
|
|
429
|
+
<pwg:Height>4961</pwg:Height>
|
|
430
|
+
</pwg:ScanRegion>
|
|
431
|
+
</pwg:ScanRegions>
|
|
432
|
+
<scan:Justification>
|
|
433
|
+
<pwg:XImagePosition>Center</pwg:XImagePosition>
|
|
434
|
+
<pwg:YImagePosition>Center</pwg:YImagePosition>
|
|
435
|
+
</scan:Justification>
|
|
436
|
+
<pwg:InputSource>{input_source}</pwg:InputSource>
|
|
437
|
+
<scan:ColorMode>{escl_mode}</scan:ColorMode>
|
|
438
|
+
<scan:XResolution>{resolution}</scan:XResolution>
|
|
439
|
+
<scan:YResolution>{resolution}</scan:YResolution>
|
|
440
|
+
<pwg:DocumentFormat>image/jpeg</pwg:DocumentFormat>
|
|
441
|
+
</scan:ScanSettings>'''
|
|
442
|
+
|
|
443
|
+
print(f'[eSCL] Sending XML:\n{scan_settings}', file=sys.stderr, flush=True)
|
|
444
|
+
|
|
445
|
+
data = scan_settings.encode('utf-8')
|
|
446
|
+
req = urllib.request.Request(url, data=data, headers={'Content-Type': 'text/xml'})
|
|
447
|
+
response = urllib.request.urlopen(req, timeout=10)
|
|
448
|
+
|
|
449
|
+
job_url = response.headers.get('Location')
|
|
450
|
+
|
|
451
|
+
if job_url:
|
|
452
|
+
# Convert relative path to absolute path
|
|
453
|
+
if job_url.startswith('/'):
|
|
454
|
+
job_url = f"http://{host}:{port}{job_url}"
|
|
455
|
+
|
|
456
|
+
print(f'[eSCL] Job created: {job_url}', file=sys.stderr, flush=True)
|
|
457
|
+
return job_url
|
|
458
|
+
|
|
459
|
+
return None
|
|
460
|
+
|
|
461
|
+
except Exception as e:
|
|
462
|
+
print(f'[eSCL] Job creation failed: {e}', file=sys.stderr, flush=True)
|
|
463
|
+
return None
|
|
464
|
+
|
|
465
|
+
def _download_scan_result(self, job_url, quiet=False):
|
|
466
|
+
"""Download scan result"""
|
|
467
|
+
try:
|
|
468
|
+
result_url = f"{job_url}/NextDocument"
|
|
469
|
+
|
|
470
|
+
if not quiet:
|
|
471
|
+
print(f'[eSCL] Attempting to download result: {result_url}', file=sys.stderr, flush=True)
|
|
472
|
+
|
|
473
|
+
response = urllib.request.urlopen(result_url, timeout=10)
|
|
474
|
+
content = response.read()
|
|
475
|
+
|
|
476
|
+
print(f'[eSCL] Download complete: {len(content)} bytes', file=sys.stderr, flush=True)
|
|
477
|
+
return content
|
|
478
|
+
|
|
479
|
+
except urllib.error.HTTPError as e:
|
|
480
|
+
if e.code == 404:
|
|
481
|
+
# 404 means not ready yet - quietly return None
|
|
482
|
+
return None
|
|
483
|
+
else:
|
|
484
|
+
if not quiet:
|
|
485
|
+
print(f'[eSCL] Download failed: HTTP {e.code}', file=sys.stderr, flush=True)
|
|
486
|
+
return None
|
|
487
|
+
except Exception as e:
|
|
488
|
+
if not quiet:
|
|
489
|
+
print(f'[eSCL] Download failed: {e}', file=sys.stderr, flush=True)
|
|
490
|
+
return None
|
|
491
|
+
|
|
492
|
+
def _check_job_status(self, job_url):
|
|
493
|
+
"""Check scan job status"""
|
|
494
|
+
try:
|
|
495
|
+
response = urllib.request.urlopen(job_url, timeout=5)
|
|
496
|
+
content = response.read()
|
|
497
|
+
|
|
498
|
+
# Debug: only print first response
|
|
499
|
+
if not hasattr(self, '_status_logged'):
|
|
500
|
+
print(f'[eSCL] Job status XML:\n{content.decode("utf-8")[:500]}', file=sys.stderr, flush=True)
|
|
501
|
+
self._status_logged = True
|
|
502
|
+
|
|
503
|
+
root = ET.fromstring(content)
|
|
504
|
+
|
|
505
|
+
# Find JobState (try multiple namespaces)
|
|
506
|
+
state = root.find('.//{%s}JobState' % PWG_NS)
|
|
507
|
+
if state is None:
|
|
508
|
+
state = root.find('.//{%s}JobState' % ESCL_NS)
|
|
509
|
+
if state is None:
|
|
510
|
+
# Try without namespace
|
|
511
|
+
state = root.find('.//JobState')
|
|
512
|
+
|
|
513
|
+
if state is not None and state.text:
|
|
514
|
+
print(f'[eSCL] Job status: {state.text}', file=sys.stderr, flush=True)
|
|
515
|
+
return state.text # 'Pending', 'Processing', 'Completed', 'Aborted', 'Canceled'
|
|
516
|
+
|
|
517
|
+
print('[eSCL] JobState tag not found', file=sys.stderr, flush=True)
|
|
518
|
+
return 'Unknown'
|
|
519
|
+
|
|
520
|
+
except Exception as e:
|
|
521
|
+
# Status check failed
|
|
522
|
+
print(f'[eSCL] Status check failed: {e}', file=sys.stderr, flush=True)
|
|
523
|
+
return 'Unknown'
|
|
524
|
+
|
|
525
|
+
def _delete_scan_job(self, job_url):
|
|
526
|
+
"""Delete scan job (scanner auto-deletes on success, so errors are not critical)"""
|
|
527
|
+
try:
|
|
528
|
+
req = urllib.request.Request(job_url, method='DELETE')
|
|
529
|
+
response = urllib.request.urlopen(req, timeout=10)
|
|
530
|
+
print(f'[eSCL] Job deletion complete', file=sys.stderr, flush=True)
|
|
531
|
+
return True
|
|
532
|
+
|
|
533
|
+
except Exception as e:
|
|
534
|
+
# On successful scan, scanner auto-deletes, so 404/500 errors are normal
|
|
535
|
+
print(f'[eSCL] Job deletion attempted (already deleted)', file=sys.stderr, flush=True)
|
|
536
|
+
return False
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def is_available():
|
|
540
|
+
"""Check if eSCL backend is available"""
|
|
541
|
+
return ESCL_AVAILABLE
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
eSCL Scanner Service Entry Point
|
|
5
|
+
Dedicated subprocess for eSCL (AirPrint) network scanners
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import tempfile
|
|
12
|
+
|
|
13
|
+
# zeroconf 라이브러리 체크
|
|
14
|
+
try:
|
|
15
|
+
from zeroconf import Zeroconf
|
|
16
|
+
ZEROCONF_AVAILABLE = True
|
|
17
|
+
except ImportError:
|
|
18
|
+
ZEROCONF_AVAILABLE = False
|
|
19
|
+
|
|
20
|
+
# PIL 라이브러리 체크
|
|
21
|
+
try:
|
|
22
|
+
from PIL import Image
|
|
23
|
+
PIL_AVAILABLE = True
|
|
24
|
+
except ImportError:
|
|
25
|
+
PIL_AVAILABLE = False
|
|
26
|
+
|
|
27
|
+
# 필수 라이브러리 체크
|
|
28
|
+
if not ZEROCONF_AVAILABLE or not PIL_AVAILABLE:
|
|
29
|
+
missing = []
|
|
30
|
+
if not ZEROCONF_AVAILABLE:
|
|
31
|
+
missing.append('zeroconf')
|
|
32
|
+
if not PIL_AVAILABLE:
|
|
33
|
+
missing.append('pillow')
|
|
34
|
+
|
|
35
|
+
print(json.dumps({
|
|
36
|
+
'success': False,
|
|
37
|
+
'error': f'Required Python packages are not installed. Install with: pip install {" ".join(missing)}'
|
|
38
|
+
}))
|
|
39
|
+
sys.exit(1)
|
|
40
|
+
|
|
41
|
+
# eSCL 백엔드 import
|
|
42
|
+
from escl_backend import ESCLBackend
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def main():
|
|
46
|
+
"""eSCL Scanner Service Main Loop"""
|
|
47
|
+
try:
|
|
48
|
+
backend = ESCLBackend()
|
|
49
|
+
print(f'[eSCL] Service started', file=sys.stderr, flush=True)
|
|
50
|
+
|
|
51
|
+
# stdin에서 명령어 읽기
|
|
52
|
+
for line in sys.stdin:
|
|
53
|
+
line = line.strip()
|
|
54
|
+
|
|
55
|
+
if not line:
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
command = json.loads(line)
|
|
60
|
+
action = command.get('action')
|
|
61
|
+
|
|
62
|
+
print(f'[eSCL] Command received: {action}', file=sys.stderr, flush=True)
|
|
63
|
+
|
|
64
|
+
if action == 'list':
|
|
65
|
+
# 스캐너 목록 조회
|
|
66
|
+
result = backend.list_scanners()
|
|
67
|
+
print(json.dumps(result), flush=True)
|
|
68
|
+
|
|
69
|
+
elif action == 'scan':
|
|
70
|
+
# 스캔 실행
|
|
71
|
+
params = command.get('params', {})
|
|
72
|
+
result = backend.scan(params)
|
|
73
|
+
|
|
74
|
+
# 이미지 데이터가 너무 크면 임시 파일로 저장
|
|
75
|
+
if result.get('success') and result.get('images'):
|
|
76
|
+
print(json.dumps(result, ensure_ascii=False), flush=True)
|
|
77
|
+
else:
|
|
78
|
+
print(json.dumps(result), flush=True)
|
|
79
|
+
|
|
80
|
+
elif action == 'capabilities':
|
|
81
|
+
# 스캐너 capabilities 조회
|
|
82
|
+
params = command.get('params', {})
|
|
83
|
+
result = backend.get_capabilities(params)
|
|
84
|
+
print(json.dumps(result), flush=True)
|
|
85
|
+
|
|
86
|
+
elif action == 'exit':
|
|
87
|
+
# Exit service
|
|
88
|
+
print(json.dumps({'success': True, 'message': 'eSCL service stopped'}), flush=True)
|
|
89
|
+
break
|
|
90
|
+
|
|
91
|
+
else:
|
|
92
|
+
# Unknown command
|
|
93
|
+
print(json.dumps({
|
|
94
|
+
'success': False,
|
|
95
|
+
'error': f'Unknown command: {action}'
|
|
96
|
+
}), flush=True)
|
|
97
|
+
|
|
98
|
+
except json.JSONDecodeError as e:
|
|
99
|
+
print(json.dumps({
|
|
100
|
+
'success': False,
|
|
101
|
+
'error': f'JSON parsing error: {str(e)}'
|
|
102
|
+
}), flush=True)
|
|
103
|
+
|
|
104
|
+
except Exception as e:
|
|
105
|
+
print(json.dumps({
|
|
106
|
+
'success': False,
|
|
107
|
+
'error': f'Command processing error: {str(e)}'
|
|
108
|
+
}), flush=True)
|
|
109
|
+
|
|
110
|
+
except Exception as e:
|
|
111
|
+
print(json.dumps({
|
|
112
|
+
'success': False,
|
|
113
|
+
'error': f'eSCL service error: {str(e)}'
|
|
114
|
+
}), flush=True)
|
|
115
|
+
sys.exit(1)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
if __name__ == '__main__':
|
|
119
|
+
main()
|