@crowsgear/escl-protocol-scanner 1.1.1 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.ko.md +177 -0
- package/README.md +177 -173
- package/dist/main.d.ts +5 -5
- package/dist/main.js +1 -1
- package/dist/services/client.js +22 -22
- package/dist/services/discovery.d.ts +1 -1
- package/dist/services/discovery.d.ts.map +1 -1
- package/dist/services/discovery.js +45 -35
- package/dist/services/discovery.js.map +1 -1
- package/package.json +70 -70
- package/python/__main__.py +23 -0
- package/python/app.py +91 -0
- package/python/base/__init__.py +2 -0
- package/python/base/service.py +23 -0
- package/python/common/__init__.py +4 -0
- package/python/common/exceptions.py +28 -0
- package/python/common/logger.py +25 -0
- package/python/common/response.py +26 -0
- package/python/decorators/__init__.py +2 -0
- package/python/decorators/scanner.py +30 -0
- package/python/escl-scanner.spec +98 -84
- package/python/services/__init__.py +2 -0
- package/python/services/escl/__init__.py +3 -0
- package/python/services/escl/discovery.py +53 -0
- package/python/services/escl/protocol.py +358 -0
- package/python/services/scanner.py +31 -0
- package/scripts/check-python-deps.js +269 -206
- package/dist/client.d.ts +0 -78
- package/dist/client.d.ts.map +0 -1
- package/dist/client.js +0 -384
- package/dist/client.js.map +0 -1
- package/dist/discovery.d.ts +0 -80
- package/dist/discovery.d.ts.map +0 -1
- package/dist/discovery.js +0 -304
- package/dist/discovery.js.map +0 -1
- package/dist/types.d.ts +0 -172
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -7
- package/dist/types.js.map +0 -1
- package/python/__pycache__/base.cpython-312.pyc +0 -0
- package/python/__pycache__/escl_backend.cpython-312.pyc +0 -0
- package/python/base.py +0 -57
- package/python/escl_backend.py +0 -559
- package/python/escl_main.py +0 -119
package/python/escl-scanner.spec
CHANGED
|
@@ -1,84 +1,98 @@
|
|
|
1
|
-
# -*- mode: python ; coding: utf-8 -*-
|
|
2
|
-
"""
|
|
3
|
-
eSCL Scanner PyInstaller Specification
|
|
4
|
-
Cross-platform build for Windows, macOS, Linux
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
import sys
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
|
|
10
|
-
block_cipher = None
|
|
11
|
-
|
|
12
|
-
a = Analysis(
|
|
13
|
-
['
|
|
14
|
-
pathex=[],
|
|
15
|
-
binaries=[],
|
|
16
|
-
datas=[],
|
|
17
|
-
hiddenimports=[
|
|
18
|
-
'
|
|
19
|
-
'
|
|
20
|
-
'
|
|
21
|
-
'
|
|
22
|
-
'
|
|
23
|
-
'
|
|
24
|
-
'
|
|
25
|
-
'
|
|
26
|
-
'
|
|
27
|
-
'
|
|
28
|
-
'
|
|
29
|
-
'
|
|
30
|
-
'
|
|
31
|
-
'
|
|
32
|
-
'zeroconf
|
|
33
|
-
'zeroconf.
|
|
34
|
-
'zeroconf.
|
|
35
|
-
'zeroconf.
|
|
36
|
-
'zeroconf.
|
|
37
|
-
'zeroconf.
|
|
38
|
-
'zeroconf.
|
|
39
|
-
'zeroconf.
|
|
40
|
-
'zeroconf.
|
|
41
|
-
'
|
|
42
|
-
'
|
|
43
|
-
'
|
|
44
|
-
'
|
|
45
|
-
'
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
'
|
|
52
|
-
'
|
|
53
|
-
'
|
|
54
|
-
'
|
|
55
|
-
'
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
1
|
+
# -*- mode: python ; coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
eSCL Scanner PyInstaller Specification
|
|
4
|
+
Cross-platform build for Windows, macOS, Linux
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
block_cipher = None
|
|
11
|
+
|
|
12
|
+
a = Analysis(
|
|
13
|
+
['__main__.py'],
|
|
14
|
+
pathex=[],
|
|
15
|
+
binaries=[],
|
|
16
|
+
datas=[],
|
|
17
|
+
hiddenimports=[
|
|
18
|
+
'common',
|
|
19
|
+
'common.logger',
|
|
20
|
+
'common.exceptions',
|
|
21
|
+
'common.response',
|
|
22
|
+
'base',
|
|
23
|
+
'base.service',
|
|
24
|
+
'services.scanner',
|
|
25
|
+
'decorators',
|
|
26
|
+
'decorators.scanner',
|
|
27
|
+
'services',
|
|
28
|
+
'services.escl',
|
|
29
|
+
'services.escl.protocol',
|
|
30
|
+
'services.escl.discovery',
|
|
31
|
+
'app',
|
|
32
|
+
'zeroconf',
|
|
33
|
+
'zeroconf._utils',
|
|
34
|
+
'zeroconf._utils.ipaddress',
|
|
35
|
+
'zeroconf._utils.time',
|
|
36
|
+
'zeroconf._handlers',
|
|
37
|
+
'zeroconf._handlers.answers',
|
|
38
|
+
'zeroconf._handlers.record_manager',
|
|
39
|
+
'zeroconf._handlers.multicast_outgoing_queue',
|
|
40
|
+
'zeroconf._handlers.query_handler',
|
|
41
|
+
'zeroconf._core',
|
|
42
|
+
'zeroconf._listener',
|
|
43
|
+
'zeroconf._engine',
|
|
44
|
+
'zeroconf._protocol',
|
|
45
|
+
'zeroconf._protocol.incoming',
|
|
46
|
+
'zeroconf._protocol.outgoing',
|
|
47
|
+
'zeroconf._services',
|
|
48
|
+
'zeroconf._services.browser',
|
|
49
|
+
'zeroconf._services.info',
|
|
50
|
+
'zeroconf._services.registry',
|
|
51
|
+
'zeroconf._dns',
|
|
52
|
+
'zeroconf._cache',
|
|
53
|
+
'zeroconf._record_update',
|
|
54
|
+
'zeroconf._updates',
|
|
55
|
+
'ifaddr',
|
|
56
|
+
'ifaddr._shared',
|
|
57
|
+
'PIL',
|
|
58
|
+
'PIL.Image',
|
|
59
|
+
'PIL._imaging',
|
|
60
|
+
],
|
|
61
|
+
hookspath=[],
|
|
62
|
+
hooksconfig={},
|
|
63
|
+
runtime_hooks=[],
|
|
64
|
+
excludes=[
|
|
65
|
+
'tkinter',
|
|
66
|
+
'matplotlib',
|
|
67
|
+
'numpy',
|
|
68
|
+
'pandas',
|
|
69
|
+
'scipy',
|
|
70
|
+
],
|
|
71
|
+
win_no_prefer_redirects=False,
|
|
72
|
+
win_private_assemblies=False,
|
|
73
|
+
cipher=block_cipher,
|
|
74
|
+
noarchive=False,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
|
78
|
+
|
|
79
|
+
exe = EXE(
|
|
80
|
+
pyz,
|
|
81
|
+
a.scripts,
|
|
82
|
+
a.binaries,
|
|
83
|
+
a.zipfiles,
|
|
84
|
+
a.datas,
|
|
85
|
+
[],
|
|
86
|
+
name='escl-scanner',
|
|
87
|
+
debug=False,
|
|
88
|
+
bootloader_ignore_signals=False,
|
|
89
|
+
strip=False,
|
|
90
|
+
upx=True,
|
|
91
|
+
upx_exclude=[],
|
|
92
|
+
runtime_tmpdir=None,
|
|
93
|
+
console=True,
|
|
94
|
+
disable_windowed_traceback=False,
|
|
95
|
+
target_arch=None,
|
|
96
|
+
codesign_identity=None,
|
|
97
|
+
entitlements_file=None,
|
|
98
|
+
)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
eSCL Scanner Discovery
|
|
5
|
+
Zeroconf service event handler for mDNS scanner discovery
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import ipaddress
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
from zeroconf import ServiceListener, Zeroconf
|
|
13
|
+
except ImportError:
|
|
14
|
+
ServiceListener = object
|
|
15
|
+
Zeroconf = None
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ESCLScannerListener(ServiceListener):
|
|
21
|
+
"""eSCL Scanner Discovery Listener"""
|
|
22
|
+
|
|
23
|
+
def __init__(self):
|
|
24
|
+
self.scanners: list[dict] = []
|
|
25
|
+
|
|
26
|
+
def add_service(self, zc: Zeroconf, type_: str, name: str) -> None:
|
|
27
|
+
"""Called when a scanner is discovered"""
|
|
28
|
+
log.info('Scanner detected: %s', name)
|
|
29
|
+
|
|
30
|
+
info = zc.get_service_info(type_, name)
|
|
31
|
+
|
|
32
|
+
if info and info.addresses:
|
|
33
|
+
host = str(ipaddress.ip_address(info.addresses[0]))
|
|
34
|
+
|
|
35
|
+
# Remove service type from mDNS name (e.g., "Canon iR-ADV C3525._uscan._tcp.local." -> "Canon iR-ADV C3525")
|
|
36
|
+
clean_name = name.split('._')[0] if '._' in name else name
|
|
37
|
+
|
|
38
|
+
scanner_info = {
|
|
39
|
+
'name': clean_name,
|
|
40
|
+
'host': host,
|
|
41
|
+
'port': info.port,
|
|
42
|
+
'type': type_
|
|
43
|
+
}
|
|
44
|
+
self.scanners.append(scanner_info)
|
|
45
|
+
log.info('Scanner added: %s (%s:%s)', clean_name, host, info.port)
|
|
46
|
+
|
|
47
|
+
def remove_service(self, zc: Zeroconf, type_: str, name: str) -> None:
|
|
48
|
+
"""Called when a scanner is removed"""
|
|
49
|
+
log.info('Scanner removed: %s', name)
|
|
50
|
+
|
|
51
|
+
def update_service(self, zc: Zeroconf, type_: str, name: str) -> None:
|
|
52
|
+
"""Called when scanner information is updated (ignored)"""
|
|
53
|
+
pass
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
eSCL (AirPrint) Scanner Service
|
|
5
|
+
HTTP-based network scanner support
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import time
|
|
9
|
+
import base64
|
|
10
|
+
import urllib.request
|
|
11
|
+
import urllib.error
|
|
12
|
+
import xml.etree.ElementTree as ET
|
|
13
|
+
from io import BytesIO
|
|
14
|
+
from decorators import handle_scanner_errors
|
|
15
|
+
from common.exceptions import ScannerError
|
|
16
|
+
from common.response import success_response
|
|
17
|
+
import logging
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
from services.escl.discovery import ESCLScannerListener
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
from zeroconf import ServiceBrowser, Zeroconf
|
|
25
|
+
from PIL import Image
|
|
26
|
+
ESCL_AVAILABLE = True
|
|
27
|
+
except ImportError:
|
|
28
|
+
ESCL_AVAILABLE = False
|
|
29
|
+
|
|
30
|
+
# eSCL namespaces
|
|
31
|
+
ESCL_NS = "http://schemas.hp.com/imaging/escl/2011/05/03"
|
|
32
|
+
PWG_NS = "http://www.pwg.org/schemas/2010/12/sm"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ESCLProtocol:
|
|
36
|
+
"""eSCL (AirPrint) service for network scanners"""
|
|
37
|
+
|
|
38
|
+
def __init__(self):
|
|
39
|
+
if not ESCL_AVAILABLE:
|
|
40
|
+
raise ImportError('eSCL libraries not found. Install with: pip install zeroconf pillow')
|
|
41
|
+
|
|
42
|
+
self.discovered_scanners: list[dict] = []
|
|
43
|
+
|
|
44
|
+
def _find_scanner(self, scanner_name: str) -> dict:
|
|
45
|
+
"""Find scanner by name from discovered scanners. Raises ScannerError if not found."""
|
|
46
|
+
if not scanner_name: raise ScannerError('No scanner selected')
|
|
47
|
+
|
|
48
|
+
scanner = next((s for s in self.discovered_scanners if s['name'] == scanner_name), None)
|
|
49
|
+
|
|
50
|
+
if not scanner: raise ScannerError(f'Scanner information not found: {scanner_name}')
|
|
51
|
+
|
|
52
|
+
return scanner
|
|
53
|
+
|
|
54
|
+
@handle_scanner_errors
|
|
55
|
+
def get_capabilities(self, params: dict) -> dict:
|
|
56
|
+
"""Retrieve and parse scanner capabilities"""
|
|
57
|
+
selected = self._find_scanner(params.get('scanner'))
|
|
58
|
+
host = selected['host']
|
|
59
|
+
port = selected['port']
|
|
60
|
+
|
|
61
|
+
# Retrieve capabilities
|
|
62
|
+
url = f"http://{host}:{port}/eSCL/ScannerCapabilities"
|
|
63
|
+
response = urllib.request.urlopen(url, timeout=5)
|
|
64
|
+
content = response.read()
|
|
65
|
+
|
|
66
|
+
root = ET.fromstring(content)
|
|
67
|
+
|
|
68
|
+
# Parse supported resolutions
|
|
69
|
+
resolutions = []
|
|
70
|
+
res_elements = root.findall('.//{%s}DiscreteResolution' % ESCL_NS)
|
|
71
|
+
for res in res_elements:
|
|
72
|
+
x_res = res.find('.//{%s}XResolution' % ESCL_NS)
|
|
73
|
+
if x_res is not None and x_res.text:
|
|
74
|
+
resolutions.append(int(x_res.text))
|
|
75
|
+
|
|
76
|
+
# Parse supported color modes
|
|
77
|
+
color_modes = []
|
|
78
|
+
mode_elements = root.findall('.//{%s}ColorMode' % ESCL_NS)
|
|
79
|
+
for mode in mode_elements:
|
|
80
|
+
if mode.text:
|
|
81
|
+
color_modes.append(mode.text)
|
|
82
|
+
|
|
83
|
+
# Check input sources (Platen, Adf support)
|
|
84
|
+
input_sources = []
|
|
85
|
+
if root.find('.//{%s}Platen' % ESCL_NS) is not None:
|
|
86
|
+
input_sources.append('Platen')
|
|
87
|
+
if root.find('.//{%s}Adf' % ESCL_NS) is not None:
|
|
88
|
+
input_sources.append('Adf')
|
|
89
|
+
|
|
90
|
+
# Remove duplicates and sort
|
|
91
|
+
resolutions = sorted(list(set(resolutions)))
|
|
92
|
+
color_modes = list(set(color_modes))
|
|
93
|
+
|
|
94
|
+
log.info('Capabilities parsed: resolutions=%s, modes=%s, sources=%s', resolutions, color_modes, input_sources)
|
|
95
|
+
|
|
96
|
+
return success_response(capabilities={
|
|
97
|
+
'resolutions': resolutions,
|
|
98
|
+
'colorModes': color_modes,
|
|
99
|
+
'inputSources': input_sources
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
@handle_scanner_errors
|
|
103
|
+
def list_scanners(self) -> dict:
|
|
104
|
+
"""Discover eSCL scanners on the network"""
|
|
105
|
+
log.info('Scanner discovery started...')
|
|
106
|
+
|
|
107
|
+
zeroconf = Zeroconf()
|
|
108
|
+
listener = ESCLScannerListener()
|
|
109
|
+
|
|
110
|
+
# eSCL service types
|
|
111
|
+
services = [
|
|
112
|
+
"_uscan._tcp.local.",
|
|
113
|
+
"_uscans._tcp.local."
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
_browsers = [ServiceBrowser(zeroconf, service, listener) for service in services]
|
|
117
|
+
|
|
118
|
+
# Wait 5 seconds for discovery
|
|
119
|
+
time.sleep(5)
|
|
120
|
+
|
|
121
|
+
zeroconf.close()
|
|
122
|
+
|
|
123
|
+
log.info('Discovery complete: %d scanner(s) found', len(listener.scanners))
|
|
124
|
+
|
|
125
|
+
# Store discovery results
|
|
126
|
+
self.discovered_scanners = listener.scanners
|
|
127
|
+
|
|
128
|
+
return success_response(scanners=listener.scanners, backend='eSCL')
|
|
129
|
+
|
|
130
|
+
@handle_scanner_errors
|
|
131
|
+
def scan(self, params: dict) -> dict:
|
|
132
|
+
"""Execute scan via eSCL protocol"""
|
|
133
|
+
dpi = params.get('dpi', 300)
|
|
134
|
+
mode = params.get('mode', 'gray')
|
|
135
|
+
source = params.get('source', 'Platen')
|
|
136
|
+
|
|
137
|
+
selected = self._find_scanner(params.get('scanner'))
|
|
138
|
+
host = selected['host']
|
|
139
|
+
port = selected['port']
|
|
140
|
+
|
|
141
|
+
log.info('Scan started: %s:%s', host, port)
|
|
142
|
+
|
|
143
|
+
# 1. Check scanner status
|
|
144
|
+
self._check_scanner_status(host, port)
|
|
145
|
+
|
|
146
|
+
# 1.5. Verify requested InputSource is supported
|
|
147
|
+
supported_sources = self._get_supported_sources(host, port)
|
|
148
|
+
if supported_sources and source not in supported_sources:
|
|
149
|
+
raise ScannerError(f'Unsupported input source: {source}', context=f'supported: {supported_sources}')
|
|
150
|
+
|
|
151
|
+
# 2. Create scan job
|
|
152
|
+
job_url = self._create_scan_job(host, port, dpi, mode, source)
|
|
153
|
+
|
|
154
|
+
# 3. Wait for scan to complete
|
|
155
|
+
self._poll_until_scan_done(host, port)
|
|
156
|
+
|
|
157
|
+
# 4. Download and process pages
|
|
158
|
+
is_adf = (source == 'Feeder')
|
|
159
|
+
scanned_images = self._download_and_process_pages(job_url, is_adf)
|
|
160
|
+
|
|
161
|
+
# 5. Delete scan job
|
|
162
|
+
self._delete_scan_job(job_url)
|
|
163
|
+
|
|
164
|
+
if not scanned_images:
|
|
165
|
+
raise ScannerError('Cannot retrieve scan results. Please verify document is loaded in scanner.')
|
|
166
|
+
|
|
167
|
+
return success_response(
|
|
168
|
+
images=scanned_images,
|
|
169
|
+
count=len(scanned_images),
|
|
170
|
+
backend='eSCL'
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def _poll_until_scan_done(self, host: str, port: int, max_wait: int = 30, poll_interval: int = 1) -> None:
|
|
174
|
+
"""Poll /eSCL/ScannerStatus until scanner returns to Idle or Stopped."""
|
|
175
|
+
log.info('Scan in progress... (polling ScannerStatus)')
|
|
176
|
+
url = f"http://{host}:{port}/eSCL/ScannerStatus"
|
|
177
|
+
elapsed = 0
|
|
178
|
+
|
|
179
|
+
while elapsed < max_wait:
|
|
180
|
+
response = urllib.request.urlopen(url, timeout=5)
|
|
181
|
+
content = response.read()
|
|
182
|
+
root = ET.fromstring(content)
|
|
183
|
+
state = root.find('.//{%s}State' % PWG_NS)
|
|
184
|
+
|
|
185
|
+
if state is not None:
|
|
186
|
+
log.debug('Scanner state: %s', state.text)
|
|
187
|
+
if state.text == 'Idle':
|
|
188
|
+
log.info('Scan completed (scanner idle)')
|
|
189
|
+
return
|
|
190
|
+
if state.text == 'Stopped':
|
|
191
|
+
raise ScannerError('Scanner stopped during scan', context='Stopped')
|
|
192
|
+
|
|
193
|
+
time.sleep(poll_interval)
|
|
194
|
+
elapsed += poll_interval
|
|
195
|
+
|
|
196
|
+
log.warning('Status polling timed out, attempting download anyway')
|
|
197
|
+
|
|
198
|
+
def _download_and_process_pages(self, job_url: str, is_adf: bool) -> list[str]:
|
|
199
|
+
"""Download scanned pages and encode as base64. Returns list of base64 strings."""
|
|
200
|
+
scanned_images = []
|
|
201
|
+
page_num = 1
|
|
202
|
+
|
|
203
|
+
while True:
|
|
204
|
+
log.info('Attempting to download page %d...', page_num)
|
|
205
|
+
image_data = self._download_scan_result(job_url)
|
|
206
|
+
|
|
207
|
+
if not image_data:
|
|
208
|
+
if page_num == 1:
|
|
209
|
+
self._delete_scan_job(job_url)
|
|
210
|
+
return []
|
|
211
|
+
log.info('Scan complete: %d total pages', page_num - 1)
|
|
212
|
+
break
|
|
213
|
+
|
|
214
|
+
encoded = self._encode_page_image(image_data, page_num)
|
|
215
|
+
if encoded:
|
|
216
|
+
scanned_images.append(encoded)
|
|
217
|
+
|
|
218
|
+
if not is_adf:
|
|
219
|
+
break
|
|
220
|
+
page_num += 1
|
|
221
|
+
|
|
222
|
+
return scanned_images
|
|
223
|
+
|
|
224
|
+
def _encode_page_image(self, image_data: bytes, page_num: int) -> str | None:
|
|
225
|
+
"""Encode a scanned page to base64 PNG. Returns base64 string or None."""
|
|
226
|
+
try:
|
|
227
|
+
img = Image.open(BytesIO(image_data))
|
|
228
|
+
log.debug('Page %d original size: %dx%d', page_num, img.size[0], img.size[1])
|
|
229
|
+
|
|
230
|
+
img = img.transpose(Image.Transpose.ROTATE_90)
|
|
231
|
+
log.debug('Page %d size after rotation: %dx%d', page_num, img.size[0], img.size[1])
|
|
232
|
+
|
|
233
|
+
buffer = BytesIO()
|
|
234
|
+
img.save(buffer, format='PNG')
|
|
235
|
+
return base64.b64encode(buffer.getvalue()).decode('utf-8')
|
|
236
|
+
|
|
237
|
+
except Exception as img_error:
|
|
238
|
+
log.error('Page %d image processing error: %s', page_num, img_error)
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
def _check_scanner_status(self, host: str, port: int) -> None:
|
|
242
|
+
"""Check scanner status. Raises ScannerError if not idle."""
|
|
243
|
+
url = f"http://{host}:{port}/eSCL/ScannerStatus"
|
|
244
|
+
response = urllib.request.urlopen(url, timeout=5)
|
|
245
|
+
content = response.read()
|
|
246
|
+
|
|
247
|
+
root = ET.fromstring(content)
|
|
248
|
+
state = root.find('.//{%s}State' % PWG_NS)
|
|
249
|
+
|
|
250
|
+
if state is None or state.text != 'Idle':
|
|
251
|
+
raise ScannerError('Scanner is not ready', context=state.text if state is not None else 'unknown')
|
|
252
|
+
|
|
253
|
+
log.info('Scanner ready')
|
|
254
|
+
|
|
255
|
+
def _get_supported_sources(self, host: str, port: int) -> list[str]:
|
|
256
|
+
"""Retrieve supported InputSource list from scanner capabilities."""
|
|
257
|
+
url = f"http://{host}:{port}/eSCL/ScannerCapabilities"
|
|
258
|
+
response = urllib.request.urlopen(url, timeout=5)
|
|
259
|
+
content = response.read()
|
|
260
|
+
|
|
261
|
+
log.debug('Capabilities XML:\n%s', content.decode('utf-8'))
|
|
262
|
+
|
|
263
|
+
root = ET.fromstring(content)
|
|
264
|
+
|
|
265
|
+
sources = root.findall('.//{%s}InputSource' % PWG_NS)
|
|
266
|
+
source_list = [s.text for s in sources if s.text]
|
|
267
|
+
log.info('Supported InputSource: %s', source_list)
|
|
268
|
+
return source_list
|
|
269
|
+
|
|
270
|
+
def _create_scan_job(self, host: str, port: int, resolution: int, color_mode: str, input_source: str) -> str:
|
|
271
|
+
"""Create scan job. Returns job URL. Raises ScannerError if job creation fails."""
|
|
272
|
+
url = f"http://{host}:{port}/eSCL/ScanJobs"
|
|
273
|
+
|
|
274
|
+
# Color mode mapping
|
|
275
|
+
mode_map = {
|
|
276
|
+
'gray': 'Grayscale8',
|
|
277
|
+
'bw': 'BlackAndWhite1',
|
|
278
|
+
'color': 'RGB24'
|
|
279
|
+
}
|
|
280
|
+
escl_mode = mode_map.get(color_mode, 'Grayscale8')
|
|
281
|
+
|
|
282
|
+
scan_settings = f'''<?xml version="1.0" encoding="UTF-8"?>
|
|
283
|
+
<scan:ScanSettings xmlns:scan="{ESCL_NS}" xmlns:pwg="{PWG_NS}">
|
|
284
|
+
<pwg:Version>2.0</pwg:Version>
|
|
285
|
+
<scan:Intent>Document</scan:Intent>
|
|
286
|
+
<pwg:ScanRegions>
|
|
287
|
+
<pwg:ScanRegion>
|
|
288
|
+
<pwg:ContentRegionUnits>escl:ThreeHundredthsOfInches</pwg:ContentRegionUnits>
|
|
289
|
+
<pwg:XOffset>0</pwg:XOffset>
|
|
290
|
+
<pwg:YOffset>0</pwg:YOffset>
|
|
291
|
+
<pwg:Width>3508</pwg:Width>
|
|
292
|
+
<pwg:Height>4961</pwg:Height>
|
|
293
|
+
</pwg:ScanRegion>
|
|
294
|
+
</pwg:ScanRegions>
|
|
295
|
+
<scan:Justification>
|
|
296
|
+
<pwg:XImagePosition>Center</pwg:XImagePosition>
|
|
297
|
+
<pwg:YImagePosition>Center</pwg:YImagePosition>
|
|
298
|
+
</scan:Justification>
|
|
299
|
+
<pwg:InputSource>{input_source}</pwg:InputSource>
|
|
300
|
+
<scan:ColorMode>{escl_mode}</scan:ColorMode>
|
|
301
|
+
<scan:XResolution>{resolution}</scan:XResolution>
|
|
302
|
+
<scan:YResolution>{resolution}</scan:YResolution>
|
|
303
|
+
<pwg:DocumentFormat>image/jpeg</pwg:DocumentFormat>
|
|
304
|
+
</scan:ScanSettings>'''
|
|
305
|
+
|
|
306
|
+
log.debug('Sending XML:\n%s', scan_settings)
|
|
307
|
+
|
|
308
|
+
data = scan_settings.encode('utf-8')
|
|
309
|
+
req = urllib.request.Request(url, data=data, headers={'Content-Type': 'text/xml'})
|
|
310
|
+
response = urllib.request.urlopen(req, timeout=10)
|
|
311
|
+
|
|
312
|
+
job_url = response.headers.get('Location')
|
|
313
|
+
if not job_url:
|
|
314
|
+
raise ScannerError('Scanner did not return job URL')
|
|
315
|
+
|
|
316
|
+
# Convert relative path to absolute path
|
|
317
|
+
if job_url.startswith('/'):
|
|
318
|
+
job_url = f"http://{host}:{port}{job_url}"
|
|
319
|
+
|
|
320
|
+
log.info('Job created: %s', job_url)
|
|
321
|
+
return job_url
|
|
322
|
+
|
|
323
|
+
def _download_scan_result(self, job_url: str, quiet: bool = False) -> bytes | None:
|
|
324
|
+
"""Download scan result"""
|
|
325
|
+
try:
|
|
326
|
+
result_url = f"{job_url}/NextDocument"
|
|
327
|
+
|
|
328
|
+
if not quiet:
|
|
329
|
+
log.debug('Attempting to download result: %s', result_url)
|
|
330
|
+
|
|
331
|
+
response = urllib.request.urlopen(result_url, timeout=10)
|
|
332
|
+
content = response.read()
|
|
333
|
+
|
|
334
|
+
log.info('Download complete: %d bytes', len(content))
|
|
335
|
+
return content
|
|
336
|
+
|
|
337
|
+
except urllib.error.HTTPError as e:
|
|
338
|
+
if e.code == 404:
|
|
339
|
+
return None
|
|
340
|
+
else:
|
|
341
|
+
if not quiet:
|
|
342
|
+
log.error('Download failed: HTTP %d', e.code)
|
|
343
|
+
return None
|
|
344
|
+
except Exception as e:
|
|
345
|
+
if not quiet:
|
|
346
|
+
log.error('Download failed: %s', e)
|
|
347
|
+
return None
|
|
348
|
+
|
|
349
|
+
def _delete_scan_job(self, job_url: str) -> None:
|
|
350
|
+
"""Delete scan job (scanner auto-deletes on success, so errors are not critical)"""
|
|
351
|
+
try:
|
|
352
|
+
req = urllib.request.Request(job_url, method='DELETE')
|
|
353
|
+
urllib.request.urlopen(req, timeout=10)
|
|
354
|
+
log.info('Job deletion complete')
|
|
355
|
+
|
|
356
|
+
except Exception:
|
|
357
|
+
# On successful scan, scanner auto-deletes, so 404/500 errors are normal
|
|
358
|
+
log.debug('Job deletion attempted (already deleted)')
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Scanner Service — main entry point for scanner operations.
|
|
5
|
+
Delegates to protocol-specific implementations (e.g. eSCL).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from base.service import Service
|
|
10
|
+
from services.escl import ESCLProtocol
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ScannerService(Service):
|
|
16
|
+
"""Scanner service that delegates to protocol-specific implementations."""
|
|
17
|
+
|
|
18
|
+
def __init__(self):
|
|
19
|
+
self.escl = ESCLProtocol()
|
|
20
|
+
|
|
21
|
+
def list_scanners(self) -> dict:
|
|
22
|
+
"""Discover scanners on the network."""
|
|
23
|
+
return self.escl.list_scanners()
|
|
24
|
+
|
|
25
|
+
def scan(self, params: dict) -> dict:
|
|
26
|
+
"""Execute scan with given parameters."""
|
|
27
|
+
return self.escl.scan(params)
|
|
28
|
+
|
|
29
|
+
def get_capabilities(self, params: dict) -> dict:
|
|
30
|
+
"""Retrieve scanner capabilities."""
|
|
31
|
+
return self.escl.get_capabilities(params)
|