Coverage for pure3270/session.py: 63%
895 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-11 20:54 +0000
« 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"""
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
14logger = logging.getLogger(__name__)
17class SessionError(Exception):
18 """Base exception for session-related errors."""
20 pass
23class ConnectionError(SessionError):
24 """Raised when connection fails."""
26 pass
29class MacroError(SessionError):
30 """Raised during macro execution errors."""
32 pass
35class Session:
36 """
37 Synchronous wrapper for AsyncSession.
39 This class provides a synchronous interface to the asynchronous 3270 session.
40 All methods use asyncio.run() to execute async operations.
41 """
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.
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.
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
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.
73 Args:
74 host: Optional host override.
75 port: Optional port override (default 23).
76 ssl_context: Optional SSL context override.
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())
90 def send(self, data: bytes) -> None:
91 """
92 Send data to the session.
94 Args:
95 data: Bytes to send.
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))
104 def read(self, timeout: float = 5.0) -> bytes:
105 """
106 Read data from the session.
108 Args:
109 timeout: Read timeout in seconds.
111 Returns:
112 Received bytes.
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))
121 def execute_macro(
122 self, macro: str, vars: Optional[Dict[str, str]] = None
123 ) -> Dict[str, Any]:
124 """
125 Execute a macro synchronously.
127 Args:
128 macro: Macro script to execute.
129 vars: Optional dictionary for variable substitution.
131 Returns:
132 Dict with execution results, e.g., {'success': bool, 'output': list}.
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))
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
147 def open(self, host: str, port: int = 23) -> None:
148 """Open connection synchronously (s3270 Open() action)."""
149 self.connect(host, port)
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())
156 def ascii(self, data: bytes) -> str:
157 """
158 Convert EBCDIC data to ASCII text (s3270 Ascii() action).
160 Args:
161 data: EBCDIC bytes to convert.
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)
170 def ebcdic(self, text: str) -> bytes:
171 """
172 Convert ASCII text to EBCDIC data (s3270 Ebcdic() action).
174 Args:
175 text: ASCII text to convert.
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)
184 def ascii1(self, byte_val: int) -> str:
185 """
186 Convert a single EBCDIC byte to ASCII character (s3270 Ascii1() action).
188 Args:
189 byte_val: EBCDIC byte value.
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)
198 def ebcdic1(self, char: str) -> int:
199 """
200 Convert a single ASCII character to EBCDIC byte (s3270 Ebcdic1() action).
202 Args:
203 char: ASCII character to convert.
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)
212 def ascii_field(self, field_index: int) -> str:
213 """
214 Convert field content to ASCII text (s3270 AsciiField() action).
216 Args:
217 field_index: Index of field to convert.
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)
226 def cursor_select(self) -> None:
227 """
228 Select field at cursor (s3270 CursorSelect() action).
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())
237 def delete_field(self) -> None:
238 """
239 Delete field at cursor (s3270 DeleteField() action).
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())
248 def echo(self, text: str) -> None:
249 """Echo text (s3270 Echo() action)."""
250 print(text)
252 def circum_not(self) -> None:
253 """
254 Toggle circumvention of field protection (s3270 CircumNot() action).
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())
263 def script(self, commands: str) -> None:
264 """
265 Execute script (s3270 Script() action).
267 Args:
268 commands: Script commands.
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))
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))
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())
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())
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))
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))
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))
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())
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")
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())
332 def erase(self) -> None:
333 """
334 Erase character at cursor (s3270 Erase() action).
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())
343 def erase_eof(self) -> None:
344 """
345 Erase from cursor to end of field (s3270 EraseEOF() action).
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())
354 def home(self) -> None:
355 """
356 Move cursor to home position (s3270 Home() action).
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())
365 def left(self) -> None:
366 """
367 Move cursor left one position (s3270 Left() action).
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())
376 def right(self) -> None:
377 """
378 Move cursor right one position (s3270 Right() action).
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())
387 def up(self) -> None:
388 """
389 Move cursor up one row (s3270 Up() action).
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())
398 def down(self) -> None:
399 """
400 Move cursor down one row (s3270 Down() action).
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())
409 def backspace(self) -> None:
410 """
411 Send backspace (s3270 BackSpace() action).
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())
420 def tab(self) -> None:
421 """
422 Move cursor to next field or right (s3270 Tab() action).
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())
431 def backtab(self) -> None:
432 """
433 Move cursor to previous field or left (s3270 BackTab() action).
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())
442 def compose(self, text: str) -> None:
443 """
444 Compose special characters or key combinations (s3270 Compose() action).
446 Args:
447 text: Text to compose, which may include special character sequences.
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))
456 def cookie(self, cookie_string: str) -> None:
457 """
458 Set HTTP cookie for web-based emulators (s3270 Cookie() action).
460 Args:
461 cookie_string: Cookie in "name=value" format.
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))
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).
474 Args:
475 pattern: Text pattern to wait for.
476 timeout: Maximum time to wait in seconds.
478 Returns:
479 True if pattern is found, False if timeout occurs.
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))
488 def fail(self, message: str) -> None:
489 """
490 Cause script to fail with a message (s3270 Fail() action).
492 Args:
493 message: Error message to display.
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))
503 def pf(self, n: int) -> None:
504 """
505 Send PF (Program Function) key synchronously (s3270 PF() action).
507 Args:
508 n: PF key number (1-24).
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))
518 def pa(self, n: int) -> None:
519 """
520 Send PA (Program Attention) key synchronously (s3270 PA() action).
522 Args:
523 n: PA key number (1-3).
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))
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
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
548class AsyncSession:
549 """
550 Asynchronous 3270 session handler.
552 Manages connection, data exchange, and screen emulation for 3270 terminals.
553 """
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.
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.
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__)
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 )
663 async def send(self, data: bytes) -> None:
664 """
665 Send data asynchronously.
667 Args:
668 data: Bytes to send.
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)
677 async def read(self, timeout: float = 5.0) -> bytes:
678 """
679 Read data asynchronously with timeout.
681 Args:
682 timeout: Read timeout in seconds (default 5.0).
684 Returns:
685 Received bytes.
687 Raises:
688 asyncio.TimeoutError: If timeout exceeded.
689 """
690 if not self._connected or not self._handler:
691 raise SessionError("Session not connected.")
693 data = await self._handler.receive_data(timeout)
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)
700 return data
702 async def _execute_single_command(self, cmd: str, vars: Dict[str, str]) -> str:
703 """
704 Execute a single simple command.
706 Args:
707 cmd: The command string.
708 vars: Variables dict.
710 Returns:
711 Output string.
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}")
723 def _evaluate_condition(self, condition: str) -> bool:
724 """
725 Evaluate a simple condition.
727 Args:
728 condition: Condition string like 'connected' or 'error'.
730 Returns:
731 bool evaluation result.
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}")
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.
750 Supports conditional branching (e.g., 'if connected: command'),
751 variable substitution (e.g., '${var}'), and nested macros (e.g., 'macro nested').
753 Args:
754 macro: Macro script string, commands separated by ';'.
755 vars: Optional dict for variables and nested macros.
757 Returns:
758 Dict with {'success': bool, 'output': list of str/dict, 'vars': dict}.
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.")
769 # Variable substitution
770 for key, value in vars.items():
771 macro = macro.replace(f"${{{key}}}", str(value))
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
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
826 async def execute(self, command: str) -> str:
827 """Execute external command (s3270 Execute() action)."""
828 import subprocess
830 result = subprocess.run(command, shell=True, capture_output=True, text=True)
831 return result.stdout + result.stderr
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)")
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)")
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
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")
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}")
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")
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}")
913 @asynccontextmanager
914 async def managed(self):
915 """
916 Async context manager for the session.
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()
928 @property
929 def connected(self) -> bool:
930 """Check if session is connected."""
931 return self._connected
933 @property
934 def tn3270_mode(self) -> bool:
935 """Check if TN3270 mode is active."""
936 return self._tn3270_mode
938 @tn3270_mode.setter
939 def tn3270_mode(self, value: bool) -> None:
940 self._tn3270_mode = value
942 @property
943 def tn3270e_mode(self) -> bool:
944 """Check if TN3270E mode is active."""
945 return self._tn3270e_mode
947 @tn3270e_mode.setter
948 def tn3270e_mode(self, value: bool) -> None:
949 self._tn3270e_mode = value
951 @property
952 def lu_name(self) -> Optional[str]:
953 """Get the LU name."""
954 return self._lu_name
956 @lu_name.setter
957 def lu_name(self, value: Optional[str]) -> None:
958 """Set the LU name."""
959 self._lu_name = value
961 @property
962 def screen(self) -> ScreenBuffer:
963 """Get the screen buffer (alias for screen_buffer for compatibility)."""
964 return self.screen_buffer
966 @property
967 def handler(self) -> Optional[TN3270Handler]:
968 """Get the handler (public interface for compatibility)."""
969 return self._handler
971 @handler.setter
972 def handler(self, value: Optional[TN3270Handler]) -> None:
973 """Set the handler (public interface for compatibility)."""
974 self._handler = value
976 async def macro(self, commands: List[str]) -> None:
977 """
978 Execute a list of macro commands using a dispatcher for maintainability.
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)
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.")
998 from .protocol.data_stream import DataStreamSender
999 from .emulation.ebcdic import translate_ascii_to_ebcdic
1001 sender = DataStreamSender()
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 }
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 }
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}")
1197 async def pf(self, n: int) -> None:
1198 """
1199 Send PF (Program Function) key (s3270 PF() action).
1201 Args:
1202 n: PF key number (1-24).
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}")
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
1232 if not self._connected or not self.handler:
1233 raise SessionError("Session not connected.")
1235 await self.submit(aid)
1237 async def pa(self, n: int) -> None:
1238 """
1239 Send PA (Program Attention) key (s3270 PA() action).
1241 Args:
1242 n: PA key number (1-3).
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}")
1251 aid_map = {1: 0x6C, 2: 0x6E, 3: 0x6B} # Standard PA1-PA3 AIDs
1252 aid = aid_map[n]
1254 if not self._connected or not self.handler:
1255 raise SessionError("Session not connected.")
1257 await self.submit(aid)
1259 async def submit(self, aid: int) -> None:
1260 """
1261 Submit modified fields with the given AID.
1263 Args:
1264 aid: Attention ID byte.
1266 Raises:
1267 SessionError: If not connected.
1268 """
1269 if not self._connected or not self.handler:
1270 raise SessionError("Session not connected.")
1272 from .protocol.data_stream import DataStreamSender
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))
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
1291 def ascii(self, data: bytes) -> str:
1292 """
1293 Convert EBCDIC data to ASCII text.
1295 Args:
1296 data: EBCDIC bytes.
1298 Returns:
1299 ASCII string.
1300 """
1301 from .emulation.ebcdic import translate_ebcdic_to_ascii
1303 return translate_ebcdic_to_ascii(data)
1305 def ebcdic(self, text: str) -> bytes:
1306 """
1307 Convert ASCII text to EBCDIC data.
1309 Args:
1310 text: ASCII text.
1312 Returns:
1313 EBCDIC bytes.
1314 """
1315 from .emulation.ebcdic import translate_ascii_to_ebcdic
1317 return translate_ascii_to_ebcdic(text)
1319 def ascii1(self, byte_val: int) -> str:
1320 """
1321 Convert a single EBCDIC byte to ASCII character.
1323 Args:
1324 byte_val: EBCDIC byte value.
1326 Returns:
1327 ASCII character.
1328 """
1329 from .emulation.ebcdic import translate_ebcdic_to_ascii
1331 return translate_ebcdic_to_ascii(bytes([byte_val]))
1333 def ebcdic1(self, char: str) -> int:
1334 """
1335 Convert a single ASCII character to EBCDIC byte.
1337 Args:
1338 char: ASCII character.
1340 Returns:
1341 EBCDIC byte value.
1342 """
1343 from .emulation.ebcdic import translate_ascii_to_ebcdic
1345 ebcdic_bytes = translate_ascii_to_ebcdic(char)
1346 return ebcdic_bytes[0] if ebcdic_bytes else 0
1348 def ascii_field(self, field_index: int) -> str:
1349 """
1350 Convert field content to ASCII text.
1352 Args:
1353 field_index: Index of field.
1355 Returns:
1356 ASCII string.
1357 """
1358 return self.screen_buffer.get_field_content(field_index)
1360 async def insert_text(self, text: str) -> None:
1361 """
1362 Insert text at current cursor position.
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()
1375 async def backtab(self) -> None:
1376 """
1377 Move cursor to previous field or left (s3270 BackTab() action).
1379 Raises:
1380 SessionError: If not connected.
1381 """
1382 if not self._connected or not self.handler:
1383 raise SessionError("Session not connected.")
1385 # Simple implementation: move left
1386 await self.left()
1388 async def home(self) -> None:
1389 """
1390 Move cursor to home position (row 0, col 0) (s3270 Home() action).
1392 Raises:
1393 SessionError: If not connected.
1394 """
1395 if not self._connected or not self.handler:
1396 raise SessionError("Session not connected.")
1398 self.screen_buffer.set_position(0, 0)
1400 async def left(self) -> None:
1401 """
1402 Move cursor left one position (s3270 Left() action).
1404 Raises:
1405 SessionError: If not connected.
1406 """
1407 if not self._connected or not self.handler:
1408 raise SessionError("Session not connected.")
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)
1418 async def right(self) -> None:
1419 """
1420 Move cursor right one position (s3270 Right() action).
1422 Raises:
1423 SessionError: If not connected.
1424 """
1425 if not self._connected or not self.handler:
1426 raise SessionError("Session not connected.")
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)
1435 async def up(self) -> None:
1436 """
1437 Move cursor up one row (s3270 Up() action).
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)
1450 async def down(self) -> None:
1451 """
1452 Move cursor down one row (s3270 Down() action).
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)
1464 async def backspace(self) -> None:
1465 """
1466 Send backspace (s3270 BackSpace() action).
1468 Raises:
1469 SessionError: If not connected.
1470 """
1471 await self.left()
1473 async def tab(self) -> None:
1474 """
1475 Move cursor to next field or right (s3270 Tab() action).
1477 Raises:
1478 SessionError: If not connected.
1479 """
1480 if not self._connected or not self.handler:
1481 raise SessionError("Session not connected.")
1483 # Simple implementation: move right
1484 await self.right()
1486 async def enter(self) -> None:
1487 """Send Enter key (s3270 Enter() action)."""
1488 await self.submit(0x7D)
1490 async def toggle_insert(self) -> None:
1491 """Toggle insert mode (s3270 ToggleInsert() action)."""
1492 self.insert_mode = not self.insert_mode
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
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
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
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
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)
1537 async def field_end(self) -> None:
1538 """Move cursor to end of field (s3270 FieldEnd() action)."""
1539 await self.end() # Alias
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
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)
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()
1561 async def dup(self) -> None:
1562 """Duplicate field (s3270 Dup() action)."""
1563 # Placeholder
1564 pass
1566 async def field_mark(self) -> None:
1567 """Mark field (s3270 FieldMark() action)."""
1568 # Placeholder
1569 pass
1571 async def flip(self) -> None:
1572 """Flip between insert and overstrike mode (s3270 Flip() action)."""
1573 await self.toggle_insert()
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)
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)
1583 async def next_word(self) -> None:
1584 """Move cursor to next word (s3270 NextWord() action)."""
1585 # Simple: move right
1586 await self.right()
1588 async def previous_word(self) -> None:
1589 """Move cursor to previous word (s3270 PreviousWord() action)."""
1590 await self.left()
1592 async def restore_input(self) -> None:
1593 """Restore input from saved (s3270 RestoreInput() action)."""
1594 # Placeholder
1595 pass
1597 async def save_input(self) -> None:
1598 """Save current input (s3270 SaveInput() action)."""
1599 # Placeholder
1600 pass
1602 async def toggle_reverse(self) -> None:
1603 """Toggle reverse video (s3270 ToggleReverse() action)."""
1604 # Placeholder
1605 pass
1607 async def circum_not(self) -> None:
1608 """Toggle circumvention of field protection (s3270 CircumNot() action)."""
1609 self.circumvent_protection = not self.circumvent_protection
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()
1620 async def clear(self) -> None:
1621 """Clear the screen (s3270 Clear() action)."""
1622 self.screen_buffer.clear()
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)")
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)")
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)
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}")
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 )
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)
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()
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)
1667 async def paste_string(self, text: str) -> None:
1668 """Paste string (s3270 PasteString() action)."""
1669 await self.insert_text(text)
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}")
1690 async def set_option(self, option: str, value: str) -> None:
1691 """Set option (s3270 Set() action)."""
1692 # Placeholder
1693 pass
1695 async def bell(self) -> None:
1696 """Ring bell (s3270 Bell() action)."""
1697 print("\a", end="") # Bell character
1699 async def pause(self, seconds: float = 1.0) -> None:
1700 """Pause for seconds (s3270 Pause() action)."""
1701 import asyncio
1703 await asyncio.sleep(seconds)
1705 async def ansi_text(self, data: bytes) -> str:
1706 """Convert EBCDIC to ANSI text (s3270 AnsiText() action)."""
1707 return self.ascii(data)
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)
1713 async def show(self) -> None:
1714 """Show screen content (s3270 Show() action)."""
1715 print(self.screen_buffer.to_text(), end="")
1717 async def snap(self) -> None:
1718 """Save screen snapshot (s3270 Snap() action)."""
1719 # Placeholder
1720 pass
1722 async def left2(self) -> None:
1723 """Move cursor left by 2 positions (s3270 Left2() action)."""
1724 await self.left()
1725 await self.left()
1727 async def right2(self) -> None:
1728 """Move cursor right by 2 positions (s3270 Right2() action)."""
1729 await self.right()
1730 await self.right()
1732 async def mono_case(self) -> None:
1733 """Toggle monocase mode (s3270 MonoCase() action)."""
1734 # Placeholder: toggle case sensitivity
1735 pass
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)
1743 async def print_text(self, text: str) -> None:
1744 """Print text (s3270 PrintText() action)."""
1745 print(text)
1747 async def prompt(self, message: str) -> str:
1748 """Prompt for input (s3270 Prompt() action)."""
1749 return input(message)
1751 async def read_buffer(self) -> bytes:
1752 """Read buffer (s3270 ReadBuffer() action)."""
1753 return bytes(self.screen_buffer.buffer)
1755 async def reconnect(self) -> None:
1756 """Reconnect to host (s3270 Reconnect() action)."""
1757 await self.close()
1758 await self.connect()
1760 async def screen_trace(self) -> None:
1761 """Trace screen (s3270 ScreenTrace() action)."""
1762 # Placeholder
1763 pass
1765 async def source(self, file: str) -> None:
1766 """Source script file (s3270 Source() action)."""
1767 # Placeholder
1768 pass
1770 async def subject_names(self) -> None:
1771 """Display SSL subject names (s3270 SubjectNames() action)."""
1772 # Placeholder
1773 pass
1775 async def sys_req(self) -> None:
1776 """Send system request (s3270 SysReq() action)."""
1777 # Placeholder
1778 pass
1780 async def toggle_option(self, option: str) -> None:
1781 """Toggle option (s3270 Toggle() action)."""
1782 # Placeholder
1783 pass
1785 async def trace(self, on: bool) -> None:
1786 """Enable/disable tracing (s3270 Trace() action)."""
1787 # Placeholder
1788 pass
1790 async def transfer(self, file: str) -> None:
1791 """Transfer file (s3270 Transfer() action)."""
1792 # Placeholder
1793 pass
1795 async def wait_condition(self, condition: str) -> None:
1796 """Wait for condition (s3270 Wait() action)."""
1797 # Placeholder
1798 pass
1800 async def compose(self, text: str) -> None:
1801 """
1802 Compose special characters or key combinations (s3270 Compose() action).
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)
1811 async def cookie(self, cookie_string: str) -> None:
1812 """
1813 Set HTTP cookie for web-based emulators (s3270 Cookie() action).
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 = {}
1822 # Parse and store the cookie
1823 if "=" in cookie_string:
1824 name, value = cookie_string.split("=", 1)
1825 self._cookies[name] = value
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).
1831 Args:
1832 pattern: Text pattern to wait for.
1833 timeout: Maximum time to wait in seconds.
1835 Returns:
1836 True if pattern is found, False if timeout occurs.
1837 """
1838 import asyncio
1839 import time
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
1850 async def fail(self, message: str) -> None:
1851 """
1852 Cause script to fail with a message (s3270 Fail() action).
1854 Args:
1855 message: Error message to display.
1857 Raises:
1858 Exception: Always raises an exception with the provided message.
1859 """
1860 raise Exception(f"Script failed: {message}")
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
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
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}")
1880 self.resources = {}
1881 i = 0
1882 while i < len(lines):
1883 line = lines[i].rstrip("\n\r")
1884 i += 1
1886 # Skip comments
1887 if line.strip().startswith("#") or line.strip().startswith("!"):
1888 continue
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
1899 if ":" not in line:
1900 continue
1902 parts = line.split(":", 1)
1903 if len(parts) != 2:
1904 continue
1906 key = parts[0].strip()
1907 value = parts[1].strip()
1909 # Only process s3270.* resources
1910 if key.startswith("s3270."):
1911 resource_key = key[6:] # Remove 's3270.' prefix
1912 self.resources[resource_key] = value
1914 logger.info(f"Loaded {len(self.resources)} resources from {file_path}")
1915 await self.apply_resources()
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
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}")
1986 self.logger.info("Resources applied successfully")
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