@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.
@@ -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()