@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.
Files changed (45) hide show
  1. package/LICENSE +21 -21
  2. package/README.ko.md +177 -0
  3. package/README.md +177 -173
  4. package/dist/main.d.ts +5 -5
  5. package/dist/main.js +1 -1
  6. package/dist/services/client.js +22 -22
  7. package/dist/services/discovery.d.ts +1 -1
  8. package/dist/services/discovery.d.ts.map +1 -1
  9. package/dist/services/discovery.js +45 -35
  10. package/dist/services/discovery.js.map +1 -1
  11. package/package.json +70 -70
  12. package/python/__main__.py +23 -0
  13. package/python/app.py +91 -0
  14. package/python/base/__init__.py +2 -0
  15. package/python/base/service.py +23 -0
  16. package/python/common/__init__.py +4 -0
  17. package/python/common/exceptions.py +28 -0
  18. package/python/common/logger.py +25 -0
  19. package/python/common/response.py +26 -0
  20. package/python/decorators/__init__.py +2 -0
  21. package/python/decorators/scanner.py +30 -0
  22. package/python/escl-scanner.spec +98 -84
  23. package/python/services/__init__.py +2 -0
  24. package/python/services/escl/__init__.py +3 -0
  25. package/python/services/escl/discovery.py +53 -0
  26. package/python/services/escl/protocol.py +358 -0
  27. package/python/services/scanner.py +31 -0
  28. package/scripts/check-python-deps.js +269 -206
  29. package/dist/client.d.ts +0 -78
  30. package/dist/client.d.ts.map +0 -1
  31. package/dist/client.js +0 -384
  32. package/dist/client.js.map +0 -1
  33. package/dist/discovery.d.ts +0 -80
  34. package/dist/discovery.d.ts.map +0 -1
  35. package/dist/discovery.js +0 -304
  36. package/dist/discovery.js.map +0 -1
  37. package/dist/types.d.ts +0 -172
  38. package/dist/types.d.ts.map +0 -1
  39. package/dist/types.js +0 -7
  40. package/dist/types.js.map +0 -1
  41. package/python/__pycache__/base.cpython-312.pyc +0 -0
  42. package/python/__pycache__/escl_backend.cpython-312.pyc +0 -0
  43. package/python/base.py +0 -57
  44. package/python/escl_backend.py +0 -559
  45. package/python/escl_main.py +0 -119
@@ -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
- ['escl_main.py'],
14
- pathex=[],
15
- binaries=[],
16
- datas=[],
17
- hiddenimports=[
18
- 'zeroconf',
19
- 'zeroconf._utils',
20
- 'zeroconf._utils.ipaddress',
21
- 'zeroconf._utils.time',
22
- 'zeroconf._handlers',
23
- 'zeroconf._handlers.answers',
24
- 'zeroconf._handlers.record_manager',
25
- 'zeroconf._handlers.multicast_outgoing_queue',
26
- 'zeroconf._handlers.query_handler',
27
- 'zeroconf._core',
28
- 'zeroconf._listener',
29
- 'zeroconf._engine',
30
- 'zeroconf._protocol',
31
- 'zeroconf._protocol.incoming',
32
- 'zeroconf._protocol.outgoing',
33
- 'zeroconf._services',
34
- 'zeroconf._services.browser',
35
- 'zeroconf._services.info',
36
- 'zeroconf._services.registry',
37
- 'zeroconf._dns',
38
- 'zeroconf._cache',
39
- 'zeroconf._record_update',
40
- 'zeroconf._updates',
41
- 'ifaddr',
42
- 'ifaddr._shared',
43
- 'PIL',
44
- 'PIL.Image',
45
- 'PIL._imaging',
46
- ],
47
- hookspath=[],
48
- hooksconfig={},
49
- runtime_hooks=[],
50
- excludes=[
51
- 'tkinter',
52
- 'matplotlib',
53
- 'numpy',
54
- 'pandas',
55
- 'scipy',
56
- ],
57
- win_no_prefer_redirects=False,
58
- win_private_assemblies=False,
59
- cipher=block_cipher,
60
- noarchive=False,
61
- )
62
-
63
- pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
64
-
65
- exe = EXE(
66
- pyz,
67
- a.scripts,
68
- a.binaries,
69
- a.zipfiles,
70
- a.datas,
71
- [],
72
- name='escl-scanner',
73
- debug=False,
74
- bootloader_ignore_signals=False,
75
- strip=False,
76
- upx=True,
77
- upx_exclude=[],
78
- runtime_tmpdir=None,
79
- console=True,
80
- disable_windowed_traceback=False,
81
- target_arch=None,
82
- codesign_identity=None,
83
- entitlements_file=None,
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,2 @@
1
+ """Scanner service implementations."""
2
+ from services.scanner import ScannerService
@@ -0,0 +1,3 @@
1
+ """eSCL (AirPrint) scanner service package."""
2
+ from services.escl.protocol import ESCLProtocol, ESCL_AVAILABLE
3
+ from services.escl.discovery import ESCLScannerListener
@@ -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)