Coverage for pure3270/session.py: 63%

895 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-09-11 20:54 +0000

1""" 

2Session management for pure3270, handling synchronous and asynchronous 3270 connections. 

3""" 

4 

5import asyncio 

6import logging 

7from contextlib import asynccontextmanager 

8from typing import Optional, Any, Dict, List 

9from .protocol.tn3270_handler import TN3270Handler 

10from .emulation.screen_buffer import ScreenBuffer 

11from .protocol.exceptions import NegotiationError 

12from .protocol.data_stream import DataStreamParser 

13 

14logger = logging.getLogger(__name__) 

15 

16 

17class SessionError(Exception): 

18 """Base exception for session-related errors.""" 

19 

20 pass 

21 

22 

23class ConnectionError(SessionError): 

24 """Raised when connection fails.""" 

25 

26 pass 

27 

28 

29class MacroError(SessionError): 

30 """Raised during macro execution errors.""" 

31 

32 pass 

33 

34 

35class Session: 

36 """ 

37 Synchronous wrapper for AsyncSession. 

38 

39 This class provides a synchronous interface to the asynchronous 3270 session. 

40 All methods use asyncio.run() to execute async operations. 

41 """ 

42 

43 def __init__( 

44 self, 

45 host: Optional[str] = None, 

46 port: int = 23, 

47 ssl_context: Optional[Any] = None, 

48 ): 

49 """ 

50 Initialize a synchronous session. 

51 

52 Args: 

53 host: The target host IP or hostname (can be set later in connect()). 

54 port: The target port (default 23 for Telnet). 

55 ssl_context: Optional SSL context for secure connections. 

56 

57 Raises: 

58 ConnectionError: If connection initialization fails. 

59 """ 

60 self._host = host 

61 self._port = port 

62 self._ssl_context = ssl_context 

63 self._async_session = None 

64 

65 def connect( 

66 self, 

67 host: Optional[str] = None, 

68 port: Optional[int] = None, 

69 ssl_context: Optional[Any] = None, 

70 ) -> None: 

71 """Connect to the 3270 host synchronously. 

72 

73 Args: 

74 host: Optional host override. 

75 port: Optional port override (default 23). 

76 ssl_context: Optional SSL context override. 

77 

78 Raises: 

79 ConnectionError: If connection fails. 

80 """ 

81 if host is not None: 

82 self._host = host 

83 if port is not None: 

84 self._port = port 

85 if ssl_context is not None: 

86 self._ssl_context = ssl_context 

87 self._async_session = AsyncSession(self._host, self._port, self._ssl_context) 

88 asyncio.run(self._async_session.connect()) 

89 

90 def send(self, data: bytes) -> None: 

91 """ 

92 Send data to the session. 

93 

94 Args: 

95 data: Bytes to send. 

96 

97 Raises: 

98 SessionError: If send fails. 

99 """ 

100 if not self._async_session: 

101 raise SessionError("Session not connected.") 

102 asyncio.run(self._async_session.send(data)) 

103 

104 def read(self, timeout: float = 5.0) -> bytes: 

105 """ 

106 Read data from the session. 

107 

108 Args: 

109 timeout: Read timeout in seconds. 

110 

111 Returns: 

112 Received bytes. 

113 

114 Raises: 

115 SessionError: If read fails. 

116 """ 

117 if not self._async_session: 

118 raise SessionError("Session not connected.") 

119 return asyncio.run(self._async_session.read(timeout)) 

120 

121 def execute_macro( 

122 self, macro: str, vars: Optional[Dict[str, str]] = None 

123 ) -> Dict[str, Any]: 

124 """ 

125 Execute a macro synchronously. 

126 

127 Args: 

128 macro: Macro script to execute. 

129 vars: Optional dictionary for variable substitution. 

130 

131 Returns: 

132 Dict with execution results, e.g., {'success': bool, 'output': list}. 

133 

134 Raises: 

135 MacroError: If macro execution fails. 

136 """ 

137 if not self._async_session: 

138 raise SessionError("Session not connected.") 

139 return asyncio.run(self._async_session.execute_macro(macro, vars)) 

140 

141 def close(self) -> None: 

142 """Close the session synchronously.""" 

143 if self._async_session: 

144 asyncio.run(self._async_session.close()) 

145 self._async_session = None 

146 

147 def open(self, host: str, port: int = 23) -> None: 

148 """Open connection synchronously (s3270 Open() action).""" 

149 self.connect(host, port) 

150 

151 def close_script(self) -> None: 

152 """Close script synchronously (s3270 CloseScript() action).""" 

153 if self._async_session: 

154 asyncio.run(self._async_session.close_script()) 

155 

156 def ascii(self, data: bytes) -> str: 

157 """ 

158 Convert EBCDIC data to ASCII text (s3270 Ascii() action). 

159 

160 Args: 

161 data: EBCDIC bytes to convert. 

162 

163 Returns: 

164 ASCII string representation. 

165 """ 

166 if not self._async_session: 

167 raise SessionError("Session not connected.") 

168 return self._async_session.ascii(data) 

169 

170 def ebcdic(self, text: str) -> bytes: 

171 """ 

172 Convert ASCII text to EBCDIC data (s3270 Ebcdic() action). 

173 

174 Args: 

175 text: ASCII text to convert. 

176 

177 Returns: 

178 EBCDIC bytes representation. 

179 """ 

180 if not self._async_session: 

181 raise SessionError("Session not connected.") 

182 return self._async_session.ebcdic(text) 

183 

184 def ascii1(self, byte_val: int) -> str: 

185 """ 

186 Convert a single EBCDIC byte to ASCII character (s3270 Ascii1() action). 

187 

188 Args: 

189 byte_val: EBCDIC byte value. 

190 

191 Returns: 

192 ASCII character. 

193 """ 

194 if not self._async_session: 

195 raise SessionError("Session not connected.") 

196 return self._async_session.ascii1(byte_val) 

197 

198 def ebcdic1(self, char: str) -> int: 

199 """ 

200 Convert a single ASCII character to EBCDIC byte (s3270 Ebcdic1() action). 

201 

202 Args: 

203 char: ASCII character to convert. 

204 

205 Returns: 

206 EBCDIC byte value. 

207 """ 

208 if not self._async_session: 

209 raise SessionError("Session not connected.") 

210 return self._async_session.ebcdic1(char) 

211 

212 def ascii_field(self, field_index: int) -> str: 

213 """ 

214 Convert field content to ASCII text (s3270 AsciiField() action). 

215 

216 Args: 

217 field_index: Index of field to convert. 

218 

219 Returns: 

220 ASCII string representation of field content. 

221 """ 

222 if not self._async_session: 

223 raise SessionError("Session not connected.") 

224 return self._async_session.ascii_field(field_index) 

225 

226 def cursor_select(self) -> None: 

227 """ 

228 Select field at cursor (s3270 CursorSelect() action). 

229 

230 Raises: 

231 SessionError: If not connected. 

232 """ 

233 if not self._async_session: 

234 raise SessionError("Session not connected.") 

235 asyncio.run(self._async_session.cursor_select()) 

236 

237 def delete_field(self) -> None: 

238 """ 

239 Delete field at cursor (s3270 DeleteField() action). 

240 

241 Raises: 

242 SessionError: If not connected. 

243 """ 

244 if not self._async_session: 

245 raise SessionError("Session not connected.") 

246 asyncio.run(self._async_session.delete_field()) 

247 

248 def echo(self, text: str) -> None: 

249 """Echo text (s3270 Echo() action).""" 

250 print(text) 

251 

252 def circum_not(self) -> None: 

253 """ 

254 Toggle circumvention of field protection (s3270 CircumNot() action). 

255 

256 Raises: 

257 SessionError: If not connected. 

258 """ 

259 if not self._async_session: 

260 raise SessionError("Session not connected.") 

261 asyncio.run(self._async_session.circum_not()) 

262 

263 def script(self, commands: str) -> None: 

264 """ 

265 Execute script (s3270 Script() action). 

266 

267 Args: 

268 commands: Script commands. 

269 

270 Raises: 

271 SessionError: If not connected. 

272 """ 

273 if not self._async_session: 

274 raise SessionError("Session not connected.") 

275 asyncio.run(self._async_session.script(commands)) 

276 

277 def execute(self, command: str) -> str: 

278 """Execute external command synchronously (s3270 Execute() action).""" 

279 if not self._async_session: 

280 raise SessionError("Session not connected.") 

281 return asyncio.run(self._async_session.execute(command)) 

282 

283 def capabilities(self) -> str: 

284 """Get capabilities synchronously (s3270 Capabilities() action).""" 

285 if not self._async_session: 

286 raise SessionError("Session not connected.") 

287 return asyncio.run(self._async_session.capabilities()) 

288 

289 def interrupt(self) -> None: 

290 """Send interrupt synchronously (s3270 Interrupt() action).""" 

291 if not self._async_session: 

292 raise SessionError("Session not connected.") 

293 asyncio.run(self._async_session.interrupt()) 

294 

295 def key(self, keyname: str) -> None: 

296 """Send key synchronously (s3270 Key() action).""" 

297 if not self._async_session: 

298 raise SessionError("Session not connected.") 

299 asyncio.run(self._async_session.key(keyname)) 

300 

301 def query(self, query_type: str = "All") -> str: 

302 """Query screen synchronously (s3270 Query() action).""" 

303 if not self._async_session: 

304 raise SessionError("Session not connected.") 

305 return asyncio.run(self._async_session.query(query_type)) 

306 

307 def set_option(self, option: str, value: str) -> None: 

308 """Set option synchronously (s3270 Set() action).""" 

309 if not self._async_session: 

310 raise SessionError("Session not connected.") 

311 asyncio.run(self._async_session.set(option, value)) 

312 

313 def exit(self) -> None: 

314 """Exit synchronously (s3270 Exit() action).""" 

315 if not self._async_session: 

316 raise SessionError("Session not connected.") 

317 asyncio.run(self._async_session.exit()) 

318 

319 def keyboard_disable(self) -> None: 

320 """Disable keyboard input (s3270 KeyboardDisable() action).""" 

321 if not self._async_session: 

322 raise SessionError("Session not connected.") 

323 self._async_session.keyboard_disabled = True 

324 logger.info("Keyboard disabled") 

325 

326 def enter(self) -> None: 

327 """Send Enter key synchronously (s3270 Enter() action).""" 

328 if not self._async_session: 

329 raise SessionError("Session not connected.") 

330 asyncio.run(self._async_session.enter()) 

331 

332 def erase(self) -> None: 

333 """ 

334 Erase character at cursor (s3270 Erase() action). 

335 

336 Raises: 

337 SessionError: If not connected. 

338 """ 

339 if not self._async_session: 

340 raise SessionError("Session not connected.") 

341 asyncio.run(self._async_session.erase()) 

342 

343 def erase_eof(self) -> None: 

344 """ 

345 Erase from cursor to end of field (s3270 EraseEOF() action). 

346 

347 Raises: 

348 SessionError: If not connected. 

349 """ 

350 if not self._async_session: 

351 raise SessionError("Session not connected.") 

352 asyncio.run(self._async_session.erase_eof()) 

353 

354 def home(self) -> None: 

355 """ 

356 Move cursor to home position (s3270 Home() action). 

357 

358 Raises: 

359 SessionError: If not connected. 

360 """ 

361 if not self._async_session: 

362 raise SessionError("Session not connected.") 

363 asyncio.run(self._async_session.home()) 

364 

365 def left(self) -> None: 

366 """ 

367 Move cursor left one position (s3270 Left() action). 

368 

369 Raises: 

370 SessionError: If not connected. 

371 """ 

372 if not self._async_session: 

373 raise SessionError("Session not connected.") 

374 asyncio.run(self._async_session.left()) 

375 

376 def right(self) -> None: 

377 """ 

378 Move cursor right one position (s3270 Right() action). 

379 

380 Raises: 

381 SessionError: If not connected. 

382 """ 

383 if not self._async_session: 

384 raise SessionError("Session not connected.") 

385 asyncio.run(self._async_session.right()) 

386 

387 def up(self) -> None: 

388 """ 

389 Move cursor up one row (s3270 Up() action). 

390 

391 Raises: 

392 SessionError: If not connected. 

393 """ 

394 if not self._async_session: 

395 raise SessionError("Session not connected.") 

396 asyncio.run(self._async_session.up()) 

397 

398 def down(self) -> None: 

399 """ 

400 Move cursor down one row (s3270 Down() action). 

401 

402 Raises: 

403 SessionError: If not connected. 

404 """ 

405 if not self._async_session: 

406 raise SessionError("Session not connected.") 

407 asyncio.run(self._async_session.down()) 

408 

409 def backspace(self) -> None: 

410 """ 

411 Send backspace (s3270 BackSpace() action). 

412 

413 Raises: 

414 SessionError: If not connected. 

415 """ 

416 if not self._async_session: 

417 raise SessionError("Session not connected.") 

418 asyncio.run(self._async_session.backspace()) 

419 

420 def tab(self) -> None: 

421 """ 

422 Move cursor to next field or right (s3270 Tab() action). 

423 

424 Raises: 

425 SessionError: If not connected. 

426 """ 

427 if not self._async_session: 

428 raise SessionError("Session not connected.") 

429 asyncio.run(self._async_session.tab()) 

430 

431 def backtab(self) -> None: 

432 """ 

433 Move cursor to previous field or left (s3270 BackTab() action). 

434 

435 Raises: 

436 SessionError: If not connected. 

437 """ 

438 if not self._async_session: 

439 raise SessionError("Session not connected.") 

440 asyncio.run(self._async_session.backtab()) 

441 

442 def compose(self, text: str) -> None: 

443 """ 

444 Compose special characters or key combinations (s3270 Compose() action). 

445 

446 Args: 

447 text: Text to compose, which may include special character sequences. 

448 

449 Raises: 

450 SessionError: If not connected. 

451 """ 

452 if not self._async_session: 

453 raise SessionError("Session not connected.") 

454 asyncio.run(self._async_session.compose(text)) 

455 

456 def cookie(self, cookie_string: str) -> None: 

457 """ 

458 Set HTTP cookie for web-based emulators (s3270 Cookie() action). 

459 

460 Args: 

461 cookie_string: Cookie in "name=value" format. 

462 

463 Raises: 

464 SessionError: If not connected. 

465 """ 

466 if not self._async_session: 

467 raise SessionError("Session not connected.") 

468 asyncio.run(self._async_session.cookie(cookie_string)) 

469 

470 def expect(self, pattern: str, timeout: float = 10.0) -> bool: 

471 """ 

472 Wait for a pattern to appear on the screen (s3270 Expect() action). 

473 

474 Args: 

475 pattern: Text pattern to wait for. 

476 timeout: Maximum time to wait in seconds. 

477 

478 Returns: 

479 True if pattern is found, False if timeout occurs. 

480 

481 Raises: 

482 SessionError: If not connected. 

483 """ 

484 if not self._async_session: 

485 raise SessionError("Session not connected.") 

486 return asyncio.run(self._async_session.expect(pattern, timeout)) 

487 

488 def fail(self, message: str) -> None: 

489 """ 

490 Cause script to fail with a message (s3270 Fail() action). 

491 

492 Args: 

493 message: Error message to display. 

494 

495 Raises: 

496 SessionError: If not connected. 

497 Exception: Always raises an exception with the provided message. 

498 """ 

499 if not self._async_session: 

500 raise SessionError("Session not connected.") 

501 asyncio.run(self._async_session.fail(message)) 

502 

503 def pf(self, n: int) -> None: 

504 """ 

505 Send PF (Program Function) key synchronously (s3270 PF() action). 

506 

507 Args: 

508 n: PF key number (1-24). 

509 

510 Raises: 

511 ValueError: If invalid PF key number. 

512 SessionError: If not connected. 

513 """ 

514 if not self._async_session: 

515 raise SessionError("Session not connected.") 

516 asyncio.run(self._async_session.pf(n)) 

517 

518 def pa(self, n: int) -> None: 

519 """ 

520 Send PA (Program Attention) key synchronously (s3270 PA() action). 

521 

522 Args: 

523 n: PA key number (1-3). 

524 

525 Raises: 

526 ValueError: If invalid PA key number. 

527 SessionError: If not connected. 

528 """ 

529 if not self._async_session: 

530 raise SessionError("Session not connected.") 

531 asyncio.run(self._async_session.pa(n)) 

532 

533 @property 

534 def screen_buffer(self) -> ScreenBuffer: 

535 """Get the screen buffer property.""" 

536 if not self._async_session: 

537 raise SessionError("Session not connected.") 

538 return self._async_session.screen_buffer 

539 

540 @property 

541 def connected(self) -> bool: 

542 """Check if session is connected.""" 

543 if self._async_session is None: 

544 return False 

545 return self._async_session.connected 

546 

547 

548class AsyncSession: 

549 """ 

550 Asynchronous 3270 session handler. 

551 

552 Manages connection, data exchange, and screen emulation for 3270 terminals. 

553 """ 

554 

555 def __init__( 

556 self, 

557 host: Optional[str] = None, 

558 port: int = 23, 

559 ssl_context: Optional[Any] = None, 

560 ): 

561 """ 

562 Initialize the async session. 

563 

564 Args: 

565 host: The target host IP or hostname (can be set later in connect()). 

566 port: The target port (default 23 for Telnet). 

567 ssl_context: Optional SSL context for secure connections. 

568 

569 Raises: 

570 ValueError: If host or port is invalid. 

571 """ 

572 self.host = host 

573 self.port = port 

574 self.ssl_context = ssl_context 

575 self._handler: Optional[TN3270Handler] = None 

576 self.screen_buffer = ScreenBuffer() 

577 self._connected = False 

578 self.tn3270_mode = False 

579 self.tn3270e_mode = False 

580 self._lu_name: Optional[str] = None 

581 self.insert_mode = False 

582 self.circumvent_protection = False 

583 self.resources = {} 

584 self.model = "2" 

585 self.color_mode = False 

586 self.color_palette = [(0, 0, 0)] * 16 

587 self.font = "fixed" 

588 self.keymap = None 

589 self._resource_mtime = 0 

590 self.keyboard_disabled = False 

591 self.logger = logging.getLogger(__name__) 

592 

593 async def connect( 

594 self, 

595 host: Optional[str] = None, 

596 port: Optional[int] = None, 

597 ssl_context: Optional[Any] = None, 

598 ) -> None: 

599 """ 

600 Establish connection to the 3270 host. 

601 Performs TN3270 negotiation. 

602 Args: 

603 host: Optional override for host. 

604 port: Optional override for port (default 23). 

605 ssl_context: Optional override for SSL context. 

606 Raises: 

607 ValueError: If no host specified. 

608 ConnectionError: On connection failure. 

609 NegotiationError: On negotiation failure. 

610 """ 

611 if host is not None: 

612 self.host = host 

613 if port is not None: 

614 self.port = port 

615 if ssl_context is not None: 

616 self.ssl_context = ssl_context 

617 if not self.host: 

618 raise ValueError( 

619 "Host must be provided either at initialization or in connect call." 

620 ) 

621 reader, writer = await asyncio.open_connection( 

622 self.host, self.port, ssl=self.ssl_context 

623 ) 

624 logger.debug(f"Creating new TN3270Handler") 

625 if self._handler: 

626 logger.debug( 

627 f"Replacing existing handler with object ID: {id(self._handler)}" 

628 ) 

629 self._handler = TN3270Handler(reader, writer) 

630 logger.debug(f"New handler created with object ID: {id(self._handler)}") 

631 logger.debug(f"About to call negotiate on handler {id(self._handler)}") 

632 await self._handler.negotiate() 

633 logger.debug(f"Negotiate completed on handler {id(self._handler)}") 

634 self._connected = True 

635 # Fallback to ASCII if negotiation fails 

636 try: 

637 logger.debug( 

638 f"About to call _negotiate_tn3270 on handler {id(self._handler)}" 

639 ) 

640 await self._handler._negotiate_tn3270() 

641 logger.debug(f"_negotiate_tn3270 completed on handler {id(self._handler)}") 

642 self.tn3270_mode = True 

643 self.tn3270e_mode = self._handler.negotiated_tn3270e 

644 self._lu_name = self._handler.lu_name 

645 self.screen_buffer.rows = self._handler.screen_rows 

646 self.screen_buffer.cols = self._handler.screen_cols 

647 except NegotiationError as e: 

648 logger.warning( 

649 f"TN3270 negotiation failed, falling back to ASCII mode: {e}" 

650 ) 

651 if self._handler: 

652 logger.debug( 

653 f"Setting ASCII mode on handler {id(self._handler)} with negotiator {id(self._handler.negotiator)}" 

654 ) 

655 self._handler.set_ascii_mode() 

656 logger.debug( 

657 f"ASCII mode set. Handler {id(self._handler)} negotiator {id(self._handler.negotiator)} _ascii_mode = {self._handler.negotiator._ascii_mode}" 

658 ) 

659 logger.info( 

660 "Session switched to ASCII/VT100 mode (s3270 compatibility)" 

661 ) 

662 

663 async def send(self, data: bytes) -> None: 

664 """ 

665 Send data asynchronously. 

666 

667 Args: 

668 data: Bytes to send. 

669 

670 Raises: 

671 SessionError: If send fails. 

672 """ 

673 if not self._connected or not self._handler: 

674 raise SessionError("Session not connected.") 

675 await self._handler.send_data(data) 

676 

677 async def read(self, timeout: float = 5.0) -> bytes: 

678 """ 

679 Read data asynchronously with timeout. 

680 

681 Args: 

682 timeout: Read timeout in seconds (default 5.0). 

683 

684 Returns: 

685 Received bytes. 

686 

687 Raises: 

688 asyncio.TimeoutError: If timeout exceeded. 

689 """ 

690 if not self._connected or not self._handler: 

691 raise SessionError("Session not connected.") 

692 

693 data = await self._handler.receive_data(timeout) 

694 

695 # Parse the received data if in TN3270 mode 

696 if self.tn3270_mode and data: 

697 parser = DataStreamParser(self.screen_buffer) 

698 parser.parse(data) 

699 

700 return data 

701 

702 async def _execute_single_command(self, cmd: str, vars: Dict[str, str]) -> str: 

703 """ 

704 Execute a single simple command. 

705 

706 Args: 

707 cmd: The command string. 

708 vars: Variables dict. 

709 

710 Returns: 

711 Output string. 

712 

713 Raises: 

714 MacroError: If execution fails. 

715 """ 

716 try: 

717 await self.send(cmd.encode("ascii")) 

718 output = await self.read() 

719 return output.decode("ascii", errors="ignore") 

720 except Exception as e: 

721 raise MacroError(f"Command execution failed: {e}") 

722 

723 def _evaluate_condition(self, condition: str) -> bool: 

724 """ 

725 Evaluate a simple condition. 

726 

727 Args: 

728 condition: Condition string like 'connected' or 'error'. 

729 

730 Returns: 

731 bool evaluation result. 

732 

733 Raises: 

734 MacroError: Unknown condition. 

735 """ 

736 if condition == "connected": 

737 return self._connected 

738 elif condition == "error": 

739 # Simplified: assume no error for now; could track last_error 

740 return False 

741 else: 

742 raise MacroError(f"Unknown condition: {condition}") 

743 

744 async def execute_macro( 

745 self, macro: str, vars: Optional[Dict[str, str]] = None 

746 ) -> Dict[str, Any]: 

747 """ 

748 Execute a macro asynchronously with advanced parsing support. 

749 

750 Supports conditional branching (e.g., 'if connected: command'), 

751 variable substitution (e.g., '${var}'), and nested macros (e.g., 'macro nested'). 

752 

753 Args: 

754 macro: Macro script string, commands separated by ';'. 

755 vars: Optional dict for variables and nested macros. 

756 

757 Returns: 

758 Dict with {'success': bool, 'output': list of str/dict, 'vars': dict}. 

759 

760 Raises: 

761 MacroError: On parsing or execution errors. 

762 SessionError: If not connected. 

763 """ 

764 if vars is None: 

765 vars = {} 

766 if not self._connected or not self._handler: 

767 raise SessionError("Session not connected.") 

768 

769 # Variable substitution 

770 for key, value in vars.items(): 

771 macro = macro.replace(f"${{{key}}}", str(value)) 

772 

773 # Parse commands 

774 commands = [cmd.strip() for cmd in macro.split(";") if cmd.strip()] 

775 results = {"success": True, "output": [], "vars": vars.copy()} 

776 i = 0 

777 while i < len(commands): 

778 cmd = commands[i] 

779 try: 

780 if cmd.startswith("if "): 

781 if ":" not in cmd: 

782 raise MacroError("Invalid if syntax: missing ':'") 

783 condition_part, command_part = cmd.split(":", 1) 

784 condition = condition_part[3:].strip() 

785 command_part = command_part.strip() 

786 if self._evaluate_condition(condition): 

787 sub_output = await self._execute_single_command( 

788 command_part, vars 

789 ) 

790 results["output"].append(sub_output) 

791 elif cmd.startswith("macro "): 

792 macro_name = cmd[6:].strip() 

793 nested_macro = vars.get(macro_name) 

794 if nested_macro: 

795 sub_results = await self.execute_macro(nested_macro, vars) 

796 results["output"].append(sub_results) 

797 else: 

798 raise MacroError( 

799 f"Nested macro '{macro_name}' not found in vars" 

800 ) 

801 elif cmd.startswith("LoadResource(") and cmd.endswith(")"): 

802 file_path = cmd[ 

803 13:-1 

804 ].strip() # Start from index 13 to skip "LoadResource(" 

805 await self.load_resource_definitions(file_path) 

806 results["output"].append( 

807 f"Loaded resource definitions from {file_path}" 

808 ) 

809 else: 

810 # Backward compatible simple command 

811 sub_output = await self._execute_single_command(cmd, vars) 

812 results["output"].append(sub_output) 

813 except Exception as e: 

814 results["success"] = False 

815 results["output"].append(f"Error in command '{cmd}': {e}") 

816 i += 1 

817 return results 

818 

819 async def close(self) -> None: 

820 """Close the async session.""" 

821 if self._handler: 

822 await self._handler.close() 

823 self._handler = None 

824 self._connected = False 

825 

826 async def execute(self, command: str) -> str: 

827 """Execute external command (s3270 Execute() action).""" 

828 import subprocess 

829 

830 result = subprocess.run(command, shell=True, capture_output=True, text=True) 

831 return result.stdout + result.stderr 

832 

833 async def exit(self) -> None: 

834 """Exit session (s3270 Exit() action, alias to close).""" 

835 await self.close() 

836 logger.info("Session exited (Exit action)") 

837 

838 async def quit(self) -> None: 

839 """Quit session (s3270 Quit() action, alias to close).""" 

840 await self.close() 

841 logger.info("Session quit (Quit action)") 

842 

843 async def capabilities(self) -> str: 

844 """Return session capabilities (s3270 Capabilities() action).""" 

845 caps = f"TN3270: {self.tn3270_mode}, TN3270E: {self.tn3270e_mode}, LU: {self._lu_name or 'None'}, Screen: {self.screen_buffer.rows}x{self.screen_buffer.cols}" 

846 logger.info(f"Capabilities: {caps}") 

847 return caps 

848 

849 async def interrupt(self) -> None: 

850 """Send interrupt (s3270 Interrupt() action, ATTN key).""" 

851 await self.submit(0x7E) # ATTN AID 

852 logger.info("Interrupt sent") 

853 

854 async def key(self, keyname: str) -> None: 

855 """Send key (s3270 Key() action).""" 

856 # Comprehensive AID mapping for all supported keys 

857 AID_MAP = { 

858 "enter": 0x7D, 

859 "pf1": 0xF1, 

860 "pf2": 0xF2, 

861 "pf3": 0xF3, 

862 "pf4": 0xF4, 

863 "pf5": 0xF5, 

864 "pf6": 0xF6, 

865 "pf7": 0xF7, 

866 "pf8": 0xF8, 

867 "pf9": 0xF9, 

868 "pf10": 0x7A, 

869 "pf11": 0x7B, 

870 "pf12": 0x7C, 

871 "pf13": 0xC1, 

872 "pf14": 0xC2, 

873 "pf15": 0xC3, 

874 "pf16": 0xC4, 

875 "pf17": 0xC5, 

876 "pf18": 0xC6, 

877 "pf19": 0xC7, 

878 "pf20": 0xC8, 

879 "pf21": 0xC9, 

880 "pf22": 0xCA, 

881 "pf23": 0xCB, 

882 "pf24": 0xCC, 

883 "pa1": 0x6C, 

884 "pa2": 0x6E, 

885 "pa3": 0x6B, 

886 "clear": 0x6D, 

887 "attn": 0x7E, 

888 "reset": 0x7F, 

889 } 

890 aid = AID_MAP.get(keyname.lower()) 

891 if aid is None: 

892 raise ValueError(f"Unknown key: {keyname}") 

893 await self.submit(aid) 

894 logger.info(f"Key sent: {keyname}") 

895 

896 async def query(self, query_type: str = "All") -> str: 

897 """Query screen (s3270 Query() action, using screen_buffer).""" 

898 if query_type == "Format": 

899 return f"Screen format: {self.screen_buffer.rows}x{self.screen_buffer.cols}, Fields: {len(self.screen_buffer.fields)}" 

900 elif query_type == "All": 

901 return self.screen_buffer.to_text() 

902 else: 

903 raise ValueError(f"Unknown query type: {query_type}") 

904 logger.info(f"Query {query_type} executed") 

905 

906 async def set(self, option: str, value: str) -> None: 

907 """Set option (s3270 Set() action).""" 

908 if not hasattr(self, "_options"): 

909 self._options = {} 

910 self._options[option] = value 

911 logger.info(f"Set {option} to {value}") 

912 

913 @asynccontextmanager 

914 async def managed(self): 

915 """ 

916 Async context manager for the session. 

917 

918 Usage: 

919 async with session.managed(): 

920 await session.connect() 

921 # operations 

922 """ 

923 try: 

924 yield self 

925 finally: 

926 await self.close() 

927 

928 @property 

929 def connected(self) -> bool: 

930 """Check if session is connected.""" 

931 return self._connected 

932 

933 @property 

934 def tn3270_mode(self) -> bool: 

935 """Check if TN3270 mode is active.""" 

936 return self._tn3270_mode 

937 

938 @tn3270_mode.setter 

939 def tn3270_mode(self, value: bool) -> None: 

940 self._tn3270_mode = value 

941 

942 @property 

943 def tn3270e_mode(self) -> bool: 

944 """Check if TN3270E mode is active.""" 

945 return self._tn3270e_mode 

946 

947 @tn3270e_mode.setter 

948 def tn3270e_mode(self, value: bool) -> None: 

949 self._tn3270e_mode = value 

950 

951 @property 

952 def lu_name(self) -> Optional[str]: 

953 """Get the LU name.""" 

954 return self._lu_name 

955 

956 @lu_name.setter 

957 def lu_name(self, value: Optional[str]) -> None: 

958 """Set the LU name.""" 

959 self._lu_name = value 

960 

961 @property 

962 def screen(self) -> ScreenBuffer: 

963 """Get the screen buffer (alias for screen_buffer for compatibility).""" 

964 return self.screen_buffer 

965 

966 @property 

967 def handler(self) -> Optional[TN3270Handler]: 

968 """Get the handler (public interface for compatibility).""" 

969 return self._handler 

970 

971 @handler.setter 

972 def handler(self, value: Optional[TN3270Handler]) -> None: 

973 """Set the handler (public interface for compatibility).""" 

974 self._handler = value 

975 

976 async def macro(self, commands: List[str]) -> None: 

977 """ 

978 Execute a list of macro commands using a dispatcher for maintainability. 

979 

980 Args: 

981 commands: List of macro command strings. 

982 Supported formats: 

983 - 'String(text)' - Send text as EBCDIC 

984 - 'key Enter' - Send Enter key 

985 - 'key <keyname>' - Send other keys (PF1-PF24, PA1-PA3, etc.) 

986 Examples: 

987 - String(Hello World) 

988 - key enter 

989 - Execute(ls -l) 

990 

991 Raises: 

992 MacroError: If command parsing or execution fails. 

993 SessionError: If not connected. 

994 """ 

995 if not self._connected or not self.handler: 

996 raise SessionError("Session not connected.") 

997 

998 from .protocol.data_stream import DataStreamSender 

999 from .emulation.ebcdic import translate_ascii_to_ebcdic 

1000 

1001 sender = DataStreamSender() 

1002 

1003 # AID mapping for common keys 

1004 AID_MAP = { 

1005 "enter": 0x7D, 

1006 "pf1": 0xF1, 

1007 "pf2": 0xF2, 

1008 "pf3": 0xF3, 

1009 "pf4": 0xF4, 

1010 "pf5": 0xF5, 

1011 "pf6": 0xF6, 

1012 "pf7": 0xF7, 

1013 "pf8": 0xF8, 

1014 "pf9": 0xF9, 

1015 "pf10": 0x7A, 

1016 "pf11": 0x7B, 

1017 "pf12": 0x7C, 

1018 "pf13": 0xC1, 

1019 "pf14": 0xC2, 

1020 "pf15": 0xC3, 

1021 "pf16": 0xC4, 

1022 "pf17": 0xC5, 

1023 "pf18": 0xC6, 

1024 "pf19": 0xC7, 

1025 "pf20": 0xC8, 

1026 "pf21": 0xC9, 

1027 "pf22": 0xCA, 

1028 "pf23": 0xCB, 

1029 "pf24": 0xCC, 

1030 "pa1": 0x6C, 

1031 "pa2": 0x6E, 

1032 "pa3": 0x6B, 

1033 "clear": 0x6D, 

1034 "attn": 0x7E, 

1035 "reset": 0x7F, 

1036 } 

1037 

1038 # Command dispatcher for simple actions 

1039 simple_actions = { 

1040 "Interrupt()": self.interrupt, 

1041 "CircumNot()": self.circum_not, 

1042 "CursorSelect()": self.cursor_select, 

1043 "Delete()": self.delete, 

1044 "DeleteField()": self.delete_field, 

1045 "Dup()": self.dup, 

1046 "End()": self.end, 

1047 "Erase()": self.erase, 

1048 "EraseEOF()": self.erase_eof, 

1049 "EraseInput()": self.erase_input, 

1050 "FieldEnd()": self.field_end, 

1051 "FieldMark()": self.field_mark, 

1052 "Flip()": self.flip, 

1053 "Insert()": self.insert, 

1054 "NextWord()": self.next_word, 

1055 "PreviousWord()": self.previous_word, 

1056 "RestoreInput()": self.restore_input, 

1057 "SaveInput()": self.save_input, 

1058 "Tab()": self.tab, 

1059 "ToggleInsert()": self.toggle_insert, 

1060 "ToggleReverse()": self.toggle_reverse, 

1061 "Clear()": self.clear, 

1062 "Close()": self.close_session, 

1063 "CloseScript()": self.close_script, 

1064 "Disconnect()": self.disconnect, 

1065 "Exit()": self.exit, 

1066 "Info()": self.info, 

1067 "Quit()": self.quit, 

1068 "Newline()": self.newline, 

1069 "PageDown()": self.page_down, 

1070 "PageUp()": self.page_up, 

1071 "Bell()": self.bell, 

1072 "Show()": self.show, 

1073 "Snap()": self.snap, 

1074 "Left2()": self.left2, 

1075 "Right2()": self.right2, 

1076 "MonoCase()": self.mono_case, 

1077 "Compose()": self.compose, 

1078 "Cookie()": self.cookie, 

1079 "Expect()": self.expect, 

1080 "Fail()": self.fail, 

1081 } 

1082 

1083 for command in commands: 

1084 command = command.strip() 

1085 try: 

1086 if command in simple_actions: 

1087 await simple_actions[command]() 

1088 elif command.startswith("String(") and command.endswith(")"): 

1089 text = command[7:-1] 

1090 await self.insert_text(text) 

1091 elif command.startswith("key "): 

1092 key_name = command[4:].strip().lower() 

1093 aid = AID_MAP.get(key_name) 

1094 if aid is not None: 

1095 await self.submit(aid) 

1096 else: 

1097 raise MacroError(f"Unsupported key: {key_name}") 

1098 elif command.startswith("Key("): 

1099 key_name = command[4:-1].strip().lower() 

1100 await self.key(key_name) 

1101 elif command.startswith("Execute("): 

1102 cmd = command[8:-1].strip() 

1103 result = await self.execute(cmd) 

1104 print(result) 

1105 elif command == "Capabilities()": 

1106 result = await self.capabilities() 

1107 print(result) 

1108 elif command.startswith("Query("): 

1109 query_type = command[6:-1].strip() 

1110 result = await self.query(query_type) 

1111 print(result) 

1112 elif command.startswith("Set("): 

1113 args = command[4:-1].split(",") 

1114 if len(args) == 2: 

1115 option = args[0].strip() 

1116 value = args[1].strip() 

1117 await self.set(option, value) 

1118 else: 

1119 raise MacroError("Invalid Set format") 

1120 elif command.startswith("MoveCursor("): 

1121 args = command[11:-1].split(",") 

1122 if len(args) == 2: 

1123 row = int(args[0].strip()) 

1124 col = int(args[1].strip()) 

1125 await self.move_cursor(row, col) 

1126 else: 

1127 raise MacroError("Invalid MoveCursor format") 

1128 elif command.startswith("MoveCursor1("): 

1129 args = command[12:-1].split(",") 

1130 if len(args) == 2: 

1131 row = int(args[0].strip()) 

1132 col = int(args[1].strip()) 

1133 await self.move_cursor1(row, col) 

1134 else: 

1135 raise MacroError("Invalid MoveCursor1 format") 

1136 elif command.startswith("PasteString("): 

1137 text = command[12:-1] 

1138 await self.paste_string(text) 

1139 elif command.startswith("Script("): 

1140 script = command[7:-1] 

1141 await self.script(script) 

1142 elif command.startswith("Pause("): 

1143 secs = float(command[6:-1]) 

1144 await self.pause(secs) 

1145 elif command.startswith("AnsiText("): 

1146 data = command[9:-1].encode() 

1147 result = await self.ansi_text(data) 

1148 print(result) 

1149 elif command.startswith("HexString("): 

1150 hex_str = command[10:-1] 

1151 result = await self.hex_string(hex_str) 

1152 print(result) 

1153 elif command.startswith("NvtText("): 

1154 text = command[8:-1] 

1155 await self.nvt_text(text) 

1156 elif command.startswith("PrintText("): 

1157 text = command[10:-1] 

1158 await self.print_text(text) 

1159 elif command.startswith("Prompt("): 

1160 message = command[7:-1] 

1161 result = await self.prompt(message) 

1162 print(result) 

1163 elif command == "ReadBuffer()": 

1164 buffer = await self.read_buffer() 

1165 print(buffer) 

1166 elif command == "Reconnect()": 

1167 await self.reconnect() 

1168 elif command == "ScreenTrace()": 

1169 await self.screen_trace() 

1170 elif command.startswith("Source("): 

1171 file = command[7:-1] 

1172 await self.source(file) 

1173 elif command == "SubjectNames()": 

1174 await self.subject_names() 

1175 elif command == "SysReq()": 

1176 await self.sys_req() 

1177 elif command.startswith("Toggle("): 

1178 option = command[7:-1] 

1179 await self.toggle_option(option) 

1180 elif command.startswith("Trace("): 

1181 on = command[6:-1].lower() == "on" 

1182 await self.trace(on) 

1183 elif command.startswith("Transfer("): 

1184 file = command[9:-1] 

1185 await self.transfer(file) 

1186 elif command.startswith("LoadResource("): 

1187 file_path = command[12:-1].strip() 

1188 await self.load_resource_definitions(file_path) 

1189 elif command.startswith("Wait("): 

1190 condition = command[5:-1] 

1191 await self.wait_condition(condition) 

1192 else: 

1193 raise MacroError(f"Unsupported command format: {command}") 

1194 except Exception as e: 

1195 raise MacroError(f"Failed to execute command '{command}': {e}") 

1196 

1197 async def pf(self, n: int) -> None: 

1198 """ 

1199 Send PF (Program Function) key (s3270 PF() action). 

1200 

1201 Args: 

1202 n: PF key number (1-24). 

1203 

1204 Raises: 

1205 ValueError: If invalid PF key number. 

1206 SessionError: If not connected. 

1207 """ 

1208 if not 1 <= n <= 24: 

1209 raise ValueError(f"PF key must be between 1 and 24, got {n}") 

1210 

1211 # PF1-12 map directly, PF13-24 map to shifted keys 

1212 if n <= 12: 

1213 aid = 0xF0 + n # PF1=0xF1, PF2=0xF2, etc. 

1214 else: 

1215 # PF13-24 map to other AIDs (simplified mapping) 

1216 aid_map = { 

1217 13: 0xC1, 

1218 14: 0xC2, 

1219 15: 0xC3, 

1220 16: 0xC4, 

1221 17: 0xC5, 

1222 18: 0xC6, 

1223 19: 0xC7, 

1224 20: 0xC8, 

1225 21: 0xC9, 

1226 22: 0xCA, 

1227 23: 0xCB, 

1228 24: 0xCC, 

1229 } 

1230 aid = aid_map.get(n, 0xF1) # Default to PF1 

1231 

1232 if not self._connected or not self.handler: 

1233 raise SessionError("Session not connected.") 

1234 

1235 await self.submit(aid) 

1236 

1237 async def pa(self, n: int) -> None: 

1238 """ 

1239 Send PA (Program Attention) key (s3270 PA() action). 

1240 

1241 Args: 

1242 n: PA key number (1-3). 

1243 

1244 Raises: 

1245 ValueError: If invalid PA key number. 

1246 SessionError: If not connected. 

1247 """ 

1248 if not 1 <= n <= 3: 

1249 raise ValueError(f"PA key must be between 1 and 3, got {n}") 

1250 

1251 aid_map = {1: 0x6C, 2: 0x6E, 3: 0x6B} # Standard PA1-PA3 AIDs 

1252 aid = aid_map[n] 

1253 

1254 if not self._connected or not self.handler: 

1255 raise SessionError("Session not connected.") 

1256 

1257 await self.submit(aid) 

1258 

1259 async def submit(self, aid: int) -> None: 

1260 """ 

1261 Submit modified fields with the given AID. 

1262 

1263 Args: 

1264 aid: Attention ID byte. 

1265 

1266 Raises: 

1267 SessionError: If not connected. 

1268 """ 

1269 if not self._connected or not self.handler: 

1270 raise SessionError("Session not connected.") 

1271 

1272 from .protocol.data_stream import DataStreamSender 

1273 

1274 sender = DataStreamSender() 

1275 modified_fields = self.screen_buffer.read_modified_fields() 

1276 # Convert to bytes 

1277 modified_bytes = [] 

1278 for pos, content in modified_fields: 

1279 ebcdic_bytes = self.ebcdic(content) # Assuming ebcdic method exists 

1280 modified_bytes.append((pos, ebcdic_bytes)) 

1281 

1282 input_stream = sender.build_input_stream( 

1283 modified_bytes, aid, self.screen_buffer.cols 

1284 ) 

1285 await self.send(input_stream) 

1286 # Reset modified flags for sent fields 

1287 for field in self.screen_buffer.fields: 

1288 if field.modified: 

1289 field.modified = False 

1290 

1291 def ascii(self, data: bytes) -> str: 

1292 """ 

1293 Convert EBCDIC data to ASCII text. 

1294 

1295 Args: 

1296 data: EBCDIC bytes. 

1297 

1298 Returns: 

1299 ASCII string. 

1300 """ 

1301 from .emulation.ebcdic import translate_ebcdic_to_ascii 

1302 

1303 return translate_ebcdic_to_ascii(data) 

1304 

1305 def ebcdic(self, text: str) -> bytes: 

1306 """ 

1307 Convert ASCII text to EBCDIC data. 

1308 

1309 Args: 

1310 text: ASCII text. 

1311 

1312 Returns: 

1313 EBCDIC bytes. 

1314 """ 

1315 from .emulation.ebcdic import translate_ascii_to_ebcdic 

1316 

1317 return translate_ascii_to_ebcdic(text) 

1318 

1319 def ascii1(self, byte_val: int) -> str: 

1320 """ 

1321 Convert a single EBCDIC byte to ASCII character. 

1322 

1323 Args: 

1324 byte_val: EBCDIC byte value. 

1325 

1326 Returns: 

1327 ASCII character. 

1328 """ 

1329 from .emulation.ebcdic import translate_ebcdic_to_ascii 

1330 

1331 return translate_ebcdic_to_ascii(bytes([byte_val])) 

1332 

1333 def ebcdic1(self, char: str) -> int: 

1334 """ 

1335 Convert a single ASCII character to EBCDIC byte. 

1336 

1337 Args: 

1338 char: ASCII character. 

1339 

1340 Returns: 

1341 EBCDIC byte value. 

1342 """ 

1343 from .emulation.ebcdic import translate_ascii_to_ebcdic 

1344 

1345 ebcdic_bytes = translate_ascii_to_ebcdic(char) 

1346 return ebcdic_bytes[0] if ebcdic_bytes else 0 

1347 

1348 def ascii_field(self, field_index: int) -> str: 

1349 """ 

1350 Convert field content to ASCII text. 

1351 

1352 Args: 

1353 field_index: Index of field. 

1354 

1355 Returns: 

1356 ASCII string. 

1357 """ 

1358 return self.screen_buffer.get_field_content(field_index) 

1359 

1360 async def insert_text(self, text: str) -> None: 

1361 """ 

1362 Insert text at current cursor position. 

1363 

1364 Args: 

1365 text: Text to insert. 

1366 """ 

1367 ebcdic_bytes = self.ebcdic(text) 

1368 for byte in ebcdic_bytes: 

1369 row, col = self.screen_buffer.get_position() 

1370 self.screen_buffer.write_char( 

1371 byte, row, col, circumvent_protection=self.circumvent_protection 

1372 ) 

1373 await self.right() 

1374 

1375 async def backtab(self) -> None: 

1376 """ 

1377 Move cursor to previous field or left (s3270 BackTab() action). 

1378 

1379 Raises: 

1380 SessionError: If not connected. 

1381 """ 

1382 if not self._connected or not self.handler: 

1383 raise SessionError("Session not connected.") 

1384 

1385 # Simple implementation: move left 

1386 await self.left() 

1387 

1388 async def home(self) -> None: 

1389 """ 

1390 Move cursor to home position (row 0, col 0) (s3270 Home() action). 

1391 

1392 Raises: 

1393 SessionError: If not connected. 

1394 """ 

1395 if not self._connected or not self.handler: 

1396 raise SessionError("Session not connected.") 

1397 

1398 self.screen_buffer.set_position(0, 0) 

1399 

1400 async def left(self) -> None: 

1401 """ 

1402 Move cursor left one position (s3270 Left() action). 

1403 

1404 Raises: 

1405 SessionError: If not connected. 

1406 """ 

1407 if not self._connected or not self.handler: 

1408 raise SessionError("Session not connected.") 

1409 

1410 row, col = self.screen_buffer.get_position() 

1411 if col > 0: 

1412 col -= 1 

1413 else: 

1414 col = self.screen_buffer.cols - 1 

1415 row = max(0, row - 1) # Wrap to previous row 

1416 self.screen_buffer.set_position(row, col) 

1417 

1418 async def right(self) -> None: 

1419 """ 

1420 Move cursor right one position (s3270 Right() action). 

1421 

1422 Raises: 

1423 SessionError: If not connected. 

1424 """ 

1425 if not self._connected or not self.handler: 

1426 raise SessionError("Session not connected.") 

1427 

1428 row, col = self.screen_buffer.get_position() 

1429 col += 1 

1430 if col >= self.screen_buffer.cols: 

1431 col = 0 

1432 row = min(self.screen_buffer.rows - 1, row + 1) # Wrap to next row 

1433 self.screen_buffer.set_position(row, col) 

1434 

1435 async def up(self) -> None: 

1436 """ 

1437 Move cursor up one row (s3270 Up() action). 

1438 

1439 Raises: 

1440 SessionError: If not connected. 

1441 """ 

1442 # Cursor movement doesn't require connection 

1443 row, col = self.screen_buffer.get_position() 

1444 if row > 0: 

1445 row -= 1 

1446 else: 

1447 row = self.screen_buffer.rows - 1 # Wrap to bottom 

1448 self.screen_buffer.set_position(row, col) 

1449 

1450 async def down(self) -> None: 

1451 """ 

1452 Move cursor down one row (s3270 Down() action). 

1453 

1454 Raises: 

1455 SessionError: If not connected. 

1456 """ 

1457 # Cursor movement doesn't require connection 

1458 row, col = self.screen_buffer.get_position() 

1459 row += 1 

1460 if row >= self.screen_buffer.rows: 

1461 row = 0 # Wrap to top 

1462 self.screen_buffer.set_position(row, col) 

1463 

1464 async def backspace(self) -> None: 

1465 """ 

1466 Send backspace (s3270 BackSpace() action). 

1467 

1468 Raises: 

1469 SessionError: If not connected. 

1470 """ 

1471 await self.left() 

1472 

1473 async def tab(self) -> None: 

1474 """ 

1475 Move cursor to next field or right (s3270 Tab() action). 

1476 

1477 Raises: 

1478 SessionError: If not connected. 

1479 """ 

1480 if not self._connected or not self.handler: 

1481 raise SessionError("Session not connected.") 

1482 

1483 # Simple implementation: move right 

1484 await self.right() 

1485 

1486 async def enter(self) -> None: 

1487 """Send Enter key (s3270 Enter() action).""" 

1488 await self.submit(0x7D) 

1489 

1490 async def toggle_insert(self) -> None: 

1491 """Toggle insert mode (s3270 ToggleInsert() action).""" 

1492 self.insert_mode = not self.insert_mode 

1493 

1494 async def insert(self) -> None: 

1495 """Insert character at cursor (s3270 Insert() action).""" 

1496 # Toggle insert mode 

1497 self.insert_mode = not self.insert_mode 

1498 

1499 async def delete(self) -> None: 

1500 """Delete character at cursor (s3270 Delete() action).""" 

1501 row, col = self.screen_buffer.get_position() 

1502 # Shift characters left in the field 

1503 # Simplified: assume unprotected 

1504 for c in range(col, self.screen_buffer.cols - 1): 

1505 pos = row * self.screen_buffer.cols + c 

1506 next_pos = pos + 1 

1507 if next_pos < self.screen_buffer.size: 

1508 self.screen_buffer.buffer[pos] = self.screen_buffer.buffer[next_pos] 

1509 # Clear last position 

1510 last_pos = row * self.screen_buffer.cols + self.screen_buffer.cols - 1 

1511 if last_pos < self.screen_buffer.size: 

1512 self.screen_buffer.buffer[last_pos] = 0x40 # Space in EBCDIC 

1513 

1514 async def erase(self) -> None: 

1515 """Erase character at cursor (s3270 Erase() action).""" 

1516 row, col = self.screen_buffer.get_position() 

1517 pos = row * self.screen_buffer.cols + col 

1518 if pos < self.screen_buffer.size: 

1519 self.screen_buffer.buffer[pos] = 0x40 # Space 

1520 

1521 async def erase_eof(self) -> None: 

1522 """Erase from cursor to end of field (s3270 EraseEOF() action).""" 

1523 row, col = self.screen_buffer.get_position() 

1524 # Find end of field 

1525 end_col = self.screen_buffer.cols - 1 

1526 for c in range(col, self.screen_buffer.cols): 

1527 pos = row * self.screen_buffer.cols + c 

1528 if pos < self.screen_buffer.size: 

1529 self.screen_buffer.buffer[pos] = 0x40 

1530 

1531 async def end(self) -> None: 

1532 """Move cursor to end of field (s3270 End() action).""" 

1533 row, col = self.screen_buffer.get_position() 

1534 # Move to end of row for simplicity 

1535 self.screen_buffer.set_position(row, self.screen_buffer.cols - 1) 

1536 

1537 async def field_end(self) -> None: 

1538 """Move cursor to end of field (s3270 FieldEnd() action).""" 

1539 await self.end() # Alias 

1540 

1541 async def erase_input(self) -> None: 

1542 """Erase all input fields (s3270 EraseInput() action).""" 

1543 for field in self.screen_buffer.fields: 

1544 if not field.protected: 

1545 field.content = b"\x40" * len(field.content) # Space 

1546 field.modified = True 

1547 

1548 async def delete_field(self) -> None: 

1549 """Delete field at cursor (s3270 DeleteField() action).""" 

1550 row, col = self.screen_buffer.get_position() 

1551 field = self.screen_buffer.get_field_at_position(row, col) 

1552 

1553 if field and not field.protected: 

1554 # Remove the field from the buffer 

1555 self.screen_buffer.remove_field(field) 

1556 # Update cursor position 

1557 self.screen_buffer.set_position(row, 0) 

1558 # Refresh screen 

1559 self.screen_buffer.update_fields() 

1560 

1561 async def dup(self) -> None: 

1562 """Duplicate field (s3270 Dup() action).""" 

1563 # Placeholder 

1564 pass 

1565 

1566 async def field_mark(self) -> None: 

1567 """Mark field (s3270 FieldMark() action).""" 

1568 # Placeholder 

1569 pass 

1570 

1571 async def flip(self) -> None: 

1572 """Flip between insert and overstrike mode (s3270 Flip() action).""" 

1573 await self.toggle_insert() 

1574 

1575 async def move_cursor(self, row: int, col: int) -> None: 

1576 """Move cursor to specified position (s3270 MoveCursor() action).""" 

1577 self.screen_buffer.set_position(row, col) 

1578 

1579 async def move_cursor1(self, row: int, col: int) -> None: 

1580 """Move cursor to specified position (1-based) (s3270 MoveCursor1() action).""" 

1581 self.screen_buffer.set_position(row - 1, col - 1) 

1582 

1583 async def next_word(self) -> None: 

1584 """Move cursor to next word (s3270 NextWord() action).""" 

1585 # Simple: move right 

1586 await self.right() 

1587 

1588 async def previous_word(self) -> None: 

1589 """Move cursor to previous word (s3270 PreviousWord() action).""" 

1590 await self.left() 

1591 

1592 async def restore_input(self) -> None: 

1593 """Restore input from saved (s3270 RestoreInput() action).""" 

1594 # Placeholder 

1595 pass 

1596 

1597 async def save_input(self) -> None: 

1598 """Save current input (s3270 SaveInput() action).""" 

1599 # Placeholder 

1600 pass 

1601 

1602 async def toggle_reverse(self) -> None: 

1603 """Toggle reverse video (s3270 ToggleReverse() action).""" 

1604 # Placeholder 

1605 pass 

1606 

1607 async def circum_not(self) -> None: 

1608 """Toggle circumvention of field protection (s3270 CircumNot() action).""" 

1609 self.circumvent_protection = not self.circumvent_protection 

1610 

1611 async def cursor_select(self) -> None: 

1612 """Select field at cursor (s3270 CursorSelect() action).""" 

1613 row, col = self.screen_buffer.get_position() 

1614 field = self.screen_buffer.get_field_at_position(row, col) 

1615 if field: 

1616 field.selected = True 

1617 # Update screen buffer to reflect selection if needed 

1618 self.screen_buffer.update_fields() 

1619 

1620 async def clear(self) -> None: 

1621 """Clear the screen (s3270 Clear() action).""" 

1622 self.screen_buffer.clear() 

1623 

1624 async def close_session(self) -> None: 

1625 """Close the session (s3270 Close() action).""" 

1626 await self.close() 

1627 logger.info("Session closed (Close action)") 

1628 

1629 async def close_script(self) -> None: 

1630 """Close the script session (s3270 CloseScript() action).""" 

1631 await self.close() 

1632 logger.info("Script session closed (CloseScript action)") 

1633 

1634 async def open(self, host: str, port: int = 23) -> None: 

1635 """Open connection (s3270 Open() action, alias to connect with default port).""" 

1636 await self.connect(host, port) 

1637 

1638 async def disconnect(self) -> None: 

1639 """Disconnect from host (s3270 Disconnect() action).""" 

1640 await self.close() 

1641 logger.info(f"Disconnected from {self.host}:{self.port}") 

1642 

1643 async def info(self) -> None: 

1644 """Display session information (s3270 Info() action).""" 

1645 print( 

1646 f"Connected: {self._connected}, TN3270 mode: {self.tn3270_mode}, LU: {self._lu_name}" 

1647 ) 

1648 

1649 async def newline(self) -> None: 

1650 """Move to next line (s3270 Newline() action).""" 

1651 await self.down() 

1652 # Move to start of line 

1653 row, col = self.screen_buffer.get_position() 

1654 self.screen_buffer.set_position(row, 0) 

1655 

1656 async def page_down(self) -> None: 

1657 """Page down (s3270 PageDown() action).""" 

1658 for _ in range(self.screen_buffer.rows): 

1659 await self.down() 

1660 

1661 async def page_up(self) -> None: 

1662 """Page up (s3270 PageUp() action).""" 

1663 # Move to top of screen 

1664 row, col = self.screen_buffer.get_position() 

1665 self.screen_buffer.set_position(0, col) 

1666 

1667 async def paste_string(self, text: str) -> None: 

1668 """Paste string (s3270 PasteString() action).""" 

1669 await self.insert_text(text) 

1670 

1671 async def script(self, commands: str) -> None: 

1672 """Execute script (s3270 Script() action).""" 

1673 # Parse and execute commands line by line 

1674 for line in commands.split("\n"): 

1675 cmd = line.strip() 

1676 if cmd and not cmd.startswith("#"): 

1677 # Assume cmd is a method name like "cursor_select" 

1678 # Strip parentheses if present 

1679 if cmd.endswith("()"): 

1680 cmd = cmd[:-2] 

1681 try: 

1682 method = getattr(self, cmd, None) 

1683 if method: 

1684 await method() 

1685 else: 

1686 logger.warning(f"Unknown script command: {cmd}") 

1687 except Exception as e: 

1688 logger.error(f"Error executing script command '{cmd}': {e}") 

1689 

1690 async def set_option(self, option: str, value: str) -> None: 

1691 """Set option (s3270 Set() action).""" 

1692 # Placeholder 

1693 pass 

1694 

1695 async def bell(self) -> None: 

1696 """Ring bell (s3270 Bell() action).""" 

1697 print("\a", end="") # Bell character 

1698 

1699 async def pause(self, seconds: float = 1.0) -> None: 

1700 """Pause for seconds (s3270 Pause() action).""" 

1701 import asyncio 

1702 

1703 await asyncio.sleep(seconds) 

1704 

1705 async def ansi_text(self, data: bytes) -> str: 

1706 """Convert EBCDIC to ANSI text (s3270 AnsiText() action).""" 

1707 return self.ascii(data) 

1708 

1709 async def hex_string(self, hex_str: str) -> bytes: 

1710 """Convert hex string to bytes (s3270 HexString() action).""" 

1711 return bytes.fromhex(hex_str) 

1712 

1713 async def show(self) -> None: 

1714 """Show screen content (s3270 Show() action).""" 

1715 print(self.screen_buffer.to_text(), end="") 

1716 

1717 async def snap(self) -> None: 

1718 """Save screen snapshot (s3270 Snap() action).""" 

1719 # Placeholder 

1720 pass 

1721 

1722 async def left2(self) -> None: 

1723 """Move cursor left by 2 positions (s3270 Left2() action).""" 

1724 await self.left() 

1725 await self.left() 

1726 

1727 async def right2(self) -> None: 

1728 """Move cursor right by 2 positions (s3270 Right2() action).""" 

1729 await self.right() 

1730 await self.right() 

1731 

1732 async def mono_case(self) -> None: 

1733 """Toggle monocase mode (s3270 MonoCase() action).""" 

1734 # Placeholder: toggle case sensitivity 

1735 pass 

1736 

1737 async def nvt_text(self, text: str) -> None: 

1738 """Send NVT text (s3270 NvtText() action).""" 

1739 # Send as ASCII 

1740 data = text.encode("ascii") 

1741 await self.send(data) 

1742 

1743 async def print_text(self, text: str) -> None: 

1744 """Print text (s3270 PrintText() action).""" 

1745 print(text) 

1746 

1747 async def prompt(self, message: str) -> str: 

1748 """Prompt for input (s3270 Prompt() action).""" 

1749 return input(message) 

1750 

1751 async def read_buffer(self) -> bytes: 

1752 """Read buffer (s3270 ReadBuffer() action).""" 

1753 return bytes(self.screen_buffer.buffer) 

1754 

1755 async def reconnect(self) -> None: 

1756 """Reconnect to host (s3270 Reconnect() action).""" 

1757 await self.close() 

1758 await self.connect() 

1759 

1760 async def screen_trace(self) -> None: 

1761 """Trace screen (s3270 ScreenTrace() action).""" 

1762 # Placeholder 

1763 pass 

1764 

1765 async def source(self, file: str) -> None: 

1766 """Source script file (s3270 Source() action).""" 

1767 # Placeholder 

1768 pass 

1769 

1770 async def subject_names(self) -> None: 

1771 """Display SSL subject names (s3270 SubjectNames() action).""" 

1772 # Placeholder 

1773 pass 

1774 

1775 async def sys_req(self) -> None: 

1776 """Send system request (s3270 SysReq() action).""" 

1777 # Placeholder 

1778 pass 

1779 

1780 async def toggle_option(self, option: str) -> None: 

1781 """Toggle option (s3270 Toggle() action).""" 

1782 # Placeholder 

1783 pass 

1784 

1785 async def trace(self, on: bool) -> None: 

1786 """Enable/disable tracing (s3270 Trace() action).""" 

1787 # Placeholder 

1788 pass 

1789 

1790 async def transfer(self, file: str) -> None: 

1791 """Transfer file (s3270 Transfer() action).""" 

1792 # Placeholder 

1793 pass 

1794 

1795 async def wait_condition(self, condition: str) -> None: 

1796 """Wait for condition (s3270 Wait() action).""" 

1797 # Placeholder 

1798 pass 

1799 

1800 async def compose(self, text: str) -> None: 

1801 """ 

1802 Compose special characters or key combinations (s3270 Compose() action). 

1803 

1804 Args: 

1805 text: Text to compose, which may include special character sequences. 

1806 """ 

1807 # For now, we'll treat this as inserting text 

1808 # A more complete implementation might handle special character sequences 

1809 await self.insert_text(text) 

1810 

1811 async def cookie(self, cookie_string: str) -> None: 

1812 """ 

1813 Set HTTP cookie for web-based emulators (s3270 Cookie() action). 

1814 

1815 Args: 

1816 cookie_string: Cookie in "name=value" format. 

1817 """ 

1818 # Initialize cookies dict if it doesn't exist 

1819 if not hasattr(self, "_cookies"): 

1820 self._cookies = {} 

1821 

1822 # Parse and store the cookie 

1823 if "=" in cookie_string: 

1824 name, value = cookie_string.split("=", 1) 

1825 self._cookies[name] = value 

1826 

1827 async def expect(self, pattern: str, timeout: float = 10.0) -> bool: 

1828 """ 

1829 Wait for a pattern to appear on the screen (s3270 Expect() action). 

1830 

1831 Args: 

1832 pattern: Text pattern to wait for. 

1833 timeout: Maximum time to wait in seconds. 

1834 

1835 Returns: 

1836 True if pattern is found, False if timeout occurs. 

1837 """ 

1838 import asyncio 

1839 import time 

1840 

1841 start_time = time.time() 

1842 while time.time() - start_time < timeout: 

1843 screen_text = self.screen_buffer.to_text() 

1844 if pattern in screen_text: 

1845 return True 

1846 # Small delay to avoid busy waiting 

1847 await asyncio.sleep(0.1) 

1848 return False 

1849 

1850 async def fail(self, message: str) -> None: 

1851 """ 

1852 Cause script to fail with a message (s3270 Fail() action). 

1853 

1854 Args: 

1855 message: Error message to display. 

1856 

1857 Raises: 

1858 Exception: Always raises an exception with the provided message. 

1859 """ 

1860 raise Exception(f"Script failed: {message}") 

1861 

1862 async def load_resource_definitions(self, file_path: str) -> None: 

1863 """Load resource definitions from xrdb format file (s3270 resource support).""" 

1864 import os 

1865 

1866 # Check if file changed 

1867 current_mtime = os.path.getmtime(file_path) 

1868 if self._resource_mtime == current_mtime: 

1869 logger.info(f"Resource file {file_path} unchanged, skipping parse") 

1870 return 

1871 self._resource_mtime = current_mtime 

1872 

1873 # Parse xrdb file and store in self.resources 

1874 try: 

1875 with open(file_path, "r") as f: 

1876 lines = f.readlines() 

1877 except IOError as e: 

1878 raise SessionError(f"Failed to read resource file {file_path}: {e}") 

1879 

1880 self.resources = {} 

1881 i = 0 

1882 while i < len(lines): 

1883 line = lines[i].rstrip("\n\r") 

1884 i += 1 

1885 

1886 # Skip comments 

1887 if line.strip().startswith("#") or line.strip().startswith("!"): 

1888 continue 

1889 

1890 # Handle multi-line: if ends with \, append next line 

1891 while line.endswith("\\"): 

1892 if i < len(lines): 

1893 next_line = lines[i].rstrip("\n\r") 

1894 line = line[:-1].rstrip() + " " + next_line.lstrip() 

1895 i += 1 

1896 else: 

1897 break 

1898 

1899 if ":" not in line: 

1900 continue 

1901 

1902 parts = line.split(":", 1) 

1903 if len(parts) != 2: 

1904 continue 

1905 

1906 key = parts[0].strip() 

1907 value = parts[1].strip() 

1908 

1909 # Only process s3270.* resources 

1910 if key.startswith("s3270."): 

1911 resource_key = key[6:] # Remove 's3270.' prefix 

1912 self.resources[resource_key] = value 

1913 

1914 logger.info(f"Loaded {len(self.resources)} resources from {file_path}") 

1915 await self.apply_resources() 

1916 

1917 async def apply_resources(self) -> None: 

1918 """Apply parsed resources to session state.""" 

1919 if not self.resources: 

1920 self.logger.info("No resources to apply") 

1921 return 

1922 

1923 for key, value in self.resources.items(): 

1924 try: 

1925 if key.startswith("color") and key[5:].isdigit(): 

1926 idx_str = key[5:] 

1927 if idx_str.isdigit(): 

1928 idx = int(idx_str) 

1929 if 0 <= idx <= 15: 

1930 if value.startswith("#") and len(value) == 7: 

1931 hex_val = value[1:] 

1932 r = int(hex_val[0:2], 16) 

1933 g = int(hex_val[2:4], 16) 

1934 b = int(hex_val[4:6], 16) 

1935 self.color_palette[idx] = (r, g, b) 

1936 # Update screen_buffer attributes (simplified: set fg/bg to palette index or value) 

1937 # For now, set attribute bytes to RGB values, assuming buffer uses 0-255 

1938 # Actual integration may need palette index mapping 

1939 if idx < len(self.screen_buffer.attributes) // 3: 

1940 # Example: set default fg (idx 1) or bg 

1941 if idx == 1: # default fg 

1942 for i in range( 

1943 1, len(self.screen_buffer.attributes), 3 

1944 ): 

1945 self.screen_buffer.attributes[i] = ( 

1946 r # Use red as example 

1947 ) 

1948 self.logger.info( 

1949 f"Applied color{idx}: RGB({r},{g},{b}) to buffer" 

1950 ) 

1951 else: 

1952 raise ValueError("Invalid hex format") 

1953 else: 

1954 self.logger.warning(f"Color index {idx} out of range 0-15") 

1955 elif key == "font": 

1956 self.font = value 

1957 self.logger.info(f"Font set to {value}") 

1958 # Handler may not support, skip 

1959 elif key == "keymap": 

1960 self.keymap = value 

1961 self.logger.info(f"Keymap set to {value}") 

1962 # Handler may not support, skip 

1963 elif key == "ssl": 

1964 val = value.lower() 

1965 self.logger.info( 

1966 f"SSL option set to {val} (cannot change mid-session)" 

1967 ) 

1968 elif key == "model": 

1969 self.model = value 

1970 self.color_mode = value == "3" # 3279 is color model 

1971 if self.tn3270_mode and self.handler: 

1972 # If connected, may need to renegotiate model, but skip for now 

1973 pass 

1974 self.logger.info( 

1975 f"Model set to {value}, color mode: {self.color_mode}" 

1976 ) 

1977 else: 

1978 self.logger.debug(f"Unknown resource {key}: {value}") 

1979 except ValueError as e: 

1980 self.logger.warning(f"Invalid resource {key}: {e}") 

1981 # Don't raise an exception, just continue with other resources 

1982 continue 

1983 except Exception as e: 

1984 self.logger.error(f"Error applying resource {key}: {e}") 

1985 

1986 self.logger.info("Resources applied successfully") 

1987 

1988 def set_field_attribute(self, field_index: int, attr: str, value: int) -> None: 

1989 """Set field attribute (extended beyond basic protection/numeric).""" 

1990 if 0 <= field_index < len(self.screen_buffer.fields): 

1991 field = self.screen_buffer.fields[field_index] 

1992 if attr == "color": 

1993 # Set color in attributes 

1994 for row in range(field.start[0], field.end[0] + 1): 

1995 for col in range(field.start[1], field.end[1] + 1): 

1996 pos = row * self.screen_buffer.cols + col 

1997 attr_offset = pos * 3 + 1 # Foreground 

1998 if attr_offset < len(self.screen_buffer.attributes): 

1999 self.screen_buffer.attributes[attr_offset] = value 

2000 elif attr == "highlight": 

2001 for row in range(field.start[0], field.end[0] + 1): 

2002 for col in range(field.start[1], field.end[1] + 1): 

2003 pos = row * self.screen_buffer.cols + col 

2004 attr_offset = pos * 3 + 2 # Background/highlight 

2005 if attr_offset < len(self.screen_buffer.attributes): 

2006 self.screen_buffer.attributes[attr_offset] = value 

2007 # Add more as needed