ibm5250 0.1.0.dev6__tar.gz → 0.1.0.dev8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ibm5250
3
- Version: 0.1.0.dev6
3
+ Version: 0.1.0.dev8
4
4
  Summary: IBM 5250 terminal automation library
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.11
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ibm5250"
3
- version = "0.1.0.dev6"
3
+ version = "0.1.0.dev8"
4
4
  description = "IBM 5250 terminal automation library"
5
5
  requires-python = ">=3.11"
6
6
 
@@ -19,6 +19,7 @@ from .constants import (
19
19
  FFW,
20
20
  SCREEN_COLS,
21
21
  SCREEN_ROWS,
22
+ Attr,
22
23
  Command,
23
24
  Order,
24
25
  TelnetCmd,
@@ -65,6 +66,7 @@ __all__ = [
65
66
  "TelnetCmd",
66
67
  "TelnetOpt",
67
68
  "FFW",
69
+ "Attr",
68
70
  "SCREEN_ROWS",
69
71
  "SCREEN_COLS",
70
72
  # Address helpers
@@ -26,13 +26,13 @@ from dataclasses import dataclass
26
26
 
27
27
  from .constants import (
28
28
  AID,
29
- CC2,
30
29
  Command,
31
30
  TelnetCmd,
32
31
  TelnetOpt,
33
32
  TN5250EDataType,
34
33
  TN5250ERecordType,
35
34
  TN5250ESubNeg,
35
+ encode_addr,
36
36
  )
37
37
  from .exceptions import ConnectionError, ProtocolError, RecvTimeout
38
38
 
@@ -48,9 +48,6 @@ _TELNET_OPT_CMD_LEN = 3 # bytes: IAC + command + option (WILL/WONT/DO/DONT)
48
48
  # SNA / TN5250E wire-format framing constants
49
49
  _GDS_RECORD_ID_HI = 0x12 # SNA GDS record-type identifier, high byte
50
50
  _GDS_RECORD_ID_LO = 0xA0 # SNA GDS record-type identifier, low byte
51
- _VAR_HDR_OP_HI = 0x08 # TN5250E variable-header opcode byte 0
52
- _VAR_HDR_OP_LO = 0x2C # TN5250E variable-header opcode byte 1
53
- _SAVE_SCREEN_RESP_CC1 = 0x01 # CC1 value in the Save Screen response variable header
54
51
 
55
52
  # TN5250E header byte 7 (request/response flags). Real emulators set bit 0x40
56
53
  # here to signal the Attention (Attn) key -- a header-only INPUT record with no
@@ -74,6 +71,20 @@ _MAX_NEG_PASSES = 40 # Maximum Telnet option exchange iterations during negotia
74
71
 
75
72
  _TN5250E_HEADER_LEN = 10
76
73
 
74
+ # The header splits into a 6-byte fixed part (length, GDS id, flow type) and a
75
+ # variable-length part whose length is given by byte 6 -- so the 5250 payload
76
+ # begins at ``6 + header[6]`` (normally 6 + 4 = 10).
77
+ _TN5250E_FIXED_HEADER_LEN = 6
78
+
79
+
80
+ def _payload_offset(frame: bytes) -> int:
81
+ """Return the offset of the 5250 payload within a TN5250E *frame*."""
82
+ offset = _TN5250E_FIXED_HEADER_LEN + frame[_TN5250E_FIXED_HEADER_LEN]
83
+ if not _TN5250E_HEADER_LEN <= offset <= len(frame):
84
+ # Malformed variable-header length; fall back to the standard 10 bytes.
85
+ return _TN5250E_HEADER_LEN
86
+ return offset
87
+
77
88
 
78
89
  @dataclass
79
90
  class TN5250EHeader:
@@ -82,10 +93,12 @@ class TN5250EHeader:
82
93
  err_flag: int = 0x00
83
94
 
84
95
  aid: int = AID.NO_AID # AID byte placed in the variable header (default: no-AID)
96
+ cursor_row: int = 0x00
97
+ cursor_col: int = 0x00
85
98
 
86
99
  def to_bytes(self, payload_len: int) -> bytes:
87
- # 3-byte variable header: _VAR_HDR_OP_HI _VAR_HDR_OP_LO <AID>
88
- var_hdr = bytes([_VAR_HDR_OP_HI, _VAR_HDR_OP_LO, self.aid])
100
+ # 3-byte variable header: cursor row, cursor column, AID.
101
+ var_hdr = bytes([self.cursor_row, self.cursor_col, self.aid])
89
102
  var_len = len(var_hdr)
90
103
  total = payload_len + _TN5250E_HEADER_LEN + var_len
91
104
  return (
@@ -238,6 +251,8 @@ class TN5250Connection:
238
251
  *,
239
252
  data_type: int = TN5250EDataType.INPUT,
240
253
  aid: int = AID.NO_AID,
254
+ cursor_pos: int | None = None,
255
+ cols: int = 80,
241
256
  ) -> None:
242
257
  """Send a 5250 data record to the host.
243
258
 
@@ -248,16 +263,21 @@ class TN5250Connection:
248
263
  raise ConnectionError("Not connected")
249
264
 
250
265
  escaped = _iac_escape(data)
266
+ cursor_row, cursor_col = (
267
+ encode_addr(cursor_pos, cols) if cursor_pos is not None else (0, 0)
268
+ )
251
269
 
252
270
  if self._tn5250e_active:
253
271
  hdr = TN5250EHeader(
254
272
  data_type=data_type,
255
273
  aid=aid,
274
+ cursor_row=cursor_row,
275
+ cursor_col=cursor_col,
256
276
  ).to_bytes(len(escaped))
257
277
  frame = hdr + escaped
258
278
  else:
259
- # Basic TN5250: AID byte is the first byte of the payload
260
- frame = bytes([aid]) + escaped
279
+ # Basic TN5250 carries AID and cursor address in the data stream.
280
+ frame = bytes([aid, cursor_row, cursor_col]) + escaped
261
281
 
262
282
  frame += bytes([TelnetCmd.INTERPRET_AS_COMMAND, TelnetCmd.END_OF_RECORD])
263
283
 
@@ -317,23 +337,20 @@ class TN5250Connection:
317
337
  frame = _build_tn5250e_frame(req_resp=0x00, opcode=_TN5250E_OP_CANCEL_INVITE)
318
338
  self._send_frame(frame, description="Sending Cancel-Invite response")
319
339
 
320
- def send_save_screen_response(self) -> None:
340
+ def send_save_screen_response(self, save_id: bytes) -> None:
321
341
  """Send the TN5250E-level Save Screen acknowledgment.
322
342
 
323
343
  When the server issues an ESC+SAVE_SCREEN (0x04 0x02) command the
324
344
  terminal must reply with a matching TN5250E frame whose variable header
325
- encodes ESC+RESTORE_SCREEN+CC1+CC2 (mirroring what working emulators
326
- send). This is a protocol-level exchange with no 5250 data stream
327
- payload.
345
+ encodes ESC + RESTORE_SCREEN + a two-byte identifier for the screen it
346
+ just saved. The host stores that identifier and echoes it back in the
347
+ Restore Screen command when it wants that particular screen displayed
348
+ again, so every save must use a distinct *save_id*.
328
349
  """
329
350
  if not self._tn5250e_active:
330
351
  return # save/restore handshake only occurs in TN5250E mode
331
352
 
332
- # 4-byte variable header: [ESC, RESTORE_SCREEN, CC1, CC2.RESET_MDT].
333
- # byte 9 of the fixed header carries the variable-header length.
334
- var_hdr = bytes(
335
- [Command.ESC, Command.RESTORE_SCREEN, _SAVE_SCREEN_RESP_CC1, CC2.RESET_MDT]
336
- )
353
+ var_hdr = bytes([Command.ESC, Command.RESTORE_SCREEN]) + bytes(save_id)
337
354
  frame = _build_tn5250e_frame(req_resp=0x00, opcode=len(var_hdr), var_hdr=var_hdr)
338
355
  self._send_frame(frame, description="Sending Save Screen response")
339
356
 
@@ -346,8 +363,8 @@ class TN5250Connection:
346
363
  log.debug("Short TN5250E frame (%d bytes), skipping", len(frame))
347
364
  continue
348
365
  hdr = TN5250EHeader.from_bytes(frame)
349
- self._last_recv_opcode = frame[9] # byte 9: opcode / var_len
350
- payload = bytes(frame[_TN5250E_HEADER_LEN:])
366
+ self._last_recv_opcode = frame[9] # byte 9: opcode
367
+ payload = bytes(frame[_payload_offset(frame) :])
351
368
  log.debug(
352
369
  "← TN5250E type=%02X data_type=%02X payload=%d bytes",
353
370
  hdr.record_type,
@@ -357,20 +374,16 @@ class TN5250Connection:
357
374
  else:
358
375
  frame_bytes = bytes(frame)
359
376
  # IBM i sends TN5250E-formatted records even when TN5250E was
360
- # not formally negotiated. The 10-byte fixed header begins
361
- # with a big-endian 16-bit record length that equals the total
362
- # frame size. Byte 9 of the header is the variable-header
363
- # length — that many additional bytes follow before the real
364
- # 5250 payload.
377
+ # not formally negotiated. The header begins with a big-endian
378
+ # 16-bit record length that equals the total frame size, and
379
+ # byte 6 holds the length of the variable header that follows
380
+ # it — the real 5250 payload starts after that.
365
381
  if len(frame_bytes) >= _TN5250E_HEADER_LEN and int.from_bytes(
366
382
  frame_bytes[:2], "big"
367
383
  ) == len(frame_bytes):
368
384
  hdr = TN5250EHeader.from_bytes(frame_bytes)
369
385
  self._last_recv_opcode = frame_bytes[9] # byte 9: opcode
370
- var_hdr_len = frame_bytes[
371
- 9
372
- ] # RFC 2877 variable-header length field
373
- skip = _TN5250E_HEADER_LEN + var_hdr_len
386
+ skip = _payload_offset(frame_bytes)
374
387
  payload = frame_bytes[skip:]
375
388
  # Server is speaking TN5250E — our responses must include the header too
376
389
  if not self._tn5250e_active:
@@ -379,9 +392,8 @@ class TN5250Connection:
379
392
  )
380
393
  self._tn5250e_active = True
381
394
  log.debug(
382
- "← implicit TN5250E header stripped (fixed=%d var=%d) data_type=%02X payload=%d bytes",
383
- _TN5250E_HEADER_LEN,
384
- var_hdr_len,
395
+ "← implicit TN5250E header stripped (%d bytes) data_type=%02X payload=%d bytes",
396
+ skip,
385
397
  hdr.data_type,
386
398
  len(payload),
387
399
  )
@@ -83,10 +83,12 @@ class TN5250ESubNeg:
83
83
 
84
84
 
85
85
  class Command:
86
- INLINE_WRITE_STRUCTURED_FIELD = (
87
- 0x02 # Introduces an inline WSF record within a WTD stream
86
+ ESC = 0x04 # precedes every 5250 command in a record
87
+ CLEAR_UNIT = 0x40 # erase the display, reset it to the primary size (24×80)
88
+ CLEAR_UNIT_ALTERNATE = (
89
+ 0x20 # erase the display, switch to the alternate size (27×132);
90
+ # followed by a one-byte parameter (0x00 or 0x80)
88
91
  )
89
- ESC = 0x04 # precedes Write Structured Field inline
90
92
  WRITE_TO_DISPLAY = (
91
93
  0x11 # main screen update command; followed by CC1/CC2 and orders
92
94
  )
@@ -104,16 +106,16 @@ class Command:
104
106
 
105
107
  # Write to Display control characters (CC1 / CC2)
106
108
  class CC1:
107
- """Control Character 1 — first byte after WTD command.
109
+ """Control Character 1 — first byte after the WTD command.
108
110
 
109
- Only bits 0-2 are defined by the IBM 5250 spec; bits 3-7 are reserved.
110
- Keyboard lock/unlock is controlled exclusively by CC2, not CC1.
111
+ CC1 selects which Modified Data Tags and input-field buffers the host wants
112
+ reset before the write is applied. It does **not** erase the screen and it
113
+ does **not** change the screen dimensions — those are driven exclusively by
114
+ the Clear Unit (:data:`Command.CLEAR_UNIT`) and Clear Unit Alternate
115
+ (:data:`Command.CLEAR_UNIT_ALTERNATE`) commands.
111
116
  """
112
117
 
113
118
  RESET = 0x00
114
- CLEAR_UNIT = 0x20 # Erase entire screen before write
115
- CLEAR_UNIT_ALT = 0x40 # Erase screen using alternate screen dimensions
116
- CLEAR_FORMAT_TABLE = 0x60 # Discard field definitions only; no screen erase
117
119
  SOUND_ALARM = 0x80 # Audible alarm
118
120
 
119
121
 
@@ -121,11 +123,10 @@ class CC2:
121
123
  """Control Character 2 — second byte after WTD command."""
122
124
 
123
125
  RESET = 0x00
124
- UNLOCK_KEYBOARD = 0x01
125
- RESET_MDT = 0x02 # Clear all MDT bits
126
- RESET_MDT_UNLOCK = 0x03
126
+ UNLOCK_KEYBOARD = 0x08
127
+ RESET_MDT = 0x10 # Clear all MDT bits
128
+ RESET_MDT_UNLOCK = 0x18
127
129
  SET_MDT = 0x04 # Set MDT on all input fields
128
- MOVE_CURSOR = 0x08 # IC order sets cursor, not default position
129
130
 
130
131
 
131
132
  # ---------------------------------------------------------------------------
@@ -133,22 +134,32 @@ class CC2:
133
134
  # ---------------------------------------------------------------------------
134
135
 
135
136
  class Order:
137
+ """5250 orders embedded in a Write to Display data stream.
138
+
139
+ Note these are *5250* order codes -- they differ from the 3270 codes of the
140
+ same name (e.g. 5250 Repeat to Address is 0x02, not 0x3C).
141
+ """
142
+
136
143
  START_OF_HEADER = 0x01 # Start of Header (variable-length; skip past it)
144
+ REPEAT_TO_ADDRESS = 0x02 # Repeat to Address — 2 address bytes + 1 fill char
145
+ ERASE_TO_ADDRESS = (
146
+ 0x03 # Erase to Address — 2 address bytes + length + attribute types
147
+ )
137
148
  TRANSPARENT_DATA = 0x10 # Transparent Data — 2-byte length + raw EBCDIC
138
149
  SET_BUFFER_ADDRESS = 0x11 # Set Buffer Address — 2 address bytes
139
- ERASE_TO_ADDRESS = (
140
- 0x12 # Erase to Address — 2 address bytes (fill with nulls)
150
+ WRITE_EXTENDED_ATTRIBUTE = (
151
+ 0x12 # Write Extended Attribute — 2 bytes; not emulated
141
152
  )
142
153
  INSERT_CURSOR = 0x13 # Insert Cursor — 2 address bytes; marks cursor pos
143
154
  MOVE_CURSOR = 0x14 # Move Cursor — 2 address bytes
144
- GROUP_ERASE_TO_ADDRESS = 0x16 # Group Erase to Address — 2 address bytes (fill with nulls, within field group)
155
+ WRITE_DISPLAY_STRUCTURED_FIELD = (
156
+ 0x15 # Write to Display Structured Field — 2-byte length + payload
157
+ )
145
158
  START_FIELD = 0x1D # Start Field — 2-byte FFW [+ 2-byte FCW pairs]
146
- SET_ATTRIBUTE = 0x28 # Set Attribute — 1 attribute type + 1 value byte
147
- MODIFY_FIELD = 0x2C # Modify Field — like SF but doesn't clear data
148
- REPEAT_TO_ADDRESS = 0x3C # Repeat to Address — 2 address bytes + 1 fill char
149
159
 
150
- _KNOWN_ORDERS = frozenset(
151
- v for k, v in vars(Order).items() if not k.startswith("_")
160
+
161
+ KNOWN_ORDERS = frozenset(
162
+ v for k, v in vars(Order).items() if isinstance(v, int) and not k.startswith("_")
152
163
  )
153
164
 
154
165
  # ---------------------------------------------------------------------------
@@ -218,36 +229,56 @@ class AID:
218
229
 
219
230
 
220
231
  class FFW:
221
- """Field Format Word bits (byte 0 and byte 1 of the 2-byte FFW)."""
232
+ """Field Format Word bits (byte 0 and byte 1 of the 2-byte FFW).
233
+
234
+ Note the FFW carries *behaviour* only. A field's visual appearance comes
235
+ from the separate attribute byte that follows the FFW/FCWs in the SF order
236
+ (see :class:`Attr`).
237
+ """
222
238
 
223
- # Byte 0
239
+ # Byte 0 — bit 0x40 is the "this is an FFW" identifier and is always set.
240
+ IDENTIFIER = 0x40 # Marks the byte as a Field Format Word rather than an attribute
224
241
  BYPASS = 0x20 # Protected field — no input allowed (read-only)
225
242
  DUP_ENABLE = 0x10 # Dup key enabled — user can duplicate the previous field's value
226
- MDT = 0x01 # Modified Data Tag — set when the field has been touched
227
- AUTO_ENTER = 0x08 # Auto Enter — automatically submit when the field is filled
228
- FER = 0x04 # Field Exit Required — user must press Field Exit before leaving
229
-
230
- # Byte 1 (keyboard shift / display attribute)
243
+ MDT = 0x08 # Modified Data Tag — set when the field has been touched
244
+ # Byte 0, low 3 bits — field type (keyboard shift)
231
245
  ALPHA_SHIFT = 0x00 # Alphabetic shift — accepts any character
232
- ALPHA_ONLY = 0x04 # Alphabetic only — rejects digits and special characters
233
- NUM_SHIFT = 0x08 # Numeric shift — defaults keyboard to numeric mode
234
- NUM_ONLY = 0x18 # Numeric only — rejects non-numeric input
235
- KATA_SHIFT = 0x0C # Katakana shift — Japanese katakana character entry
236
- DIGITS_ONLY = 0x1C # Digits only — accepts 0–9 with no sign
237
- IO_ONLY = 0x10 # Inhibit Output only — field is write-protected from the keyboard
238
- SIGNED_NUM = 0x14 # Signed numeric — accepts digits plus a trailing sign character
239
- # Display attribute (high nibble of byte 1)
240
- DISP_NORM = 0x00 # Normal — green on black
241
- DISP_HIGH = 0x20 # High intensity — bright green on black
242
- DISP_RI = 0x40 # Reverse image — black on green
243
- DISP_RI_HIGH = 0x60 # Reverse image high intensity — bright black on green
244
- DISP_UNDER = 0x80 # Underline — green with underline
245
- DISP_UNDER_HI = 0xA0 # Underline high intensity — bright green with underline
246
- DISP_BLINK = 0xC0 # Blink — flashing field
247
- NON_DISPLAY = 0xE0 # Non-display — invisible; used for password entry
248
- DISPLAY_ATTR_MASK = (
249
- 0xE0 # Bitmask for the display attribute field (high 3 bits of byte 1)
250
- )
246
+ ALPHA_ONLY = 0x01 # Alphabetic only — rejects digits and special characters
247
+ NUM_SHIFT = 0x02 # Numeric shift — defaults keyboard to numeric mode
248
+ NUM_ONLY = 0x03 # Numeric only — rejects non-numeric input
249
+ KATA_SHIFT = 0x04 # Katakana shift — Japanese katakana character entry
250
+ DIGITS_ONLY = 0x05 # Digits only — accepts 0–9 with no sign
251
+ MAG_READER = 0x06 # Magnetic stripe reader input only
252
+ SIGNED_NUM = 0x07 # Signed numeric — accepts digits plus a trailing sign character
253
+ FIELD_TYPE_MASK = 0x07 # Bitmask for the field type bits of byte 0
254
+
255
+ # Byte 1
256
+ AUTO_ENTER = 0x80 # Auto Enter — automatically submit when the field is filled
257
+ FER = 0x40 # Field Exit Required — user must press Field Exit before leaving
258
+ MONOCASE = 0x20 # Monocase — lower-case input is folded to upper case
259
+ MANDATORY = 0x08 # Mandatory entry — the field may not be left empty
260
+ RIGHT_ZERO = 0x05 # Right-adjust, zero fill
261
+ RIGHT_BLANK = 0x06 # Right-adjust, blank fill
262
+ MANDATORY_FILL = 0x07 # Mandatory fill — the field must be filled completely
263
+ MANDATORY_FILL_MASK = 0x07 # Bitmask for the adjust/fill bits of byte 1
264
+
265
+
266
+ class Attr:
267
+ """5250 display attribute byte values (0x20-0x3F).
268
+
269
+ Every attribute occupies one display position and governs the appearance of
270
+ the characters that follow it until the next attribute byte.
271
+ """
272
+
273
+ NORMAL = 0x20 # Green
274
+ REVERSE = 0x21 # Green, reverse image
275
+ HIGH_INTENSITY = 0x22 # White
276
+ UNDERLINE = 0x24 # Green, underlined
277
+ NON_DISPLAY = 0x27 # Invisible — used for passwords and command strings
278
+ RED = 0x28 # Red
279
+ BASE = 0x20 # All attribute bytes have this bit pattern in their high bits
280
+ MASK = 0xE0 # ``byte & MASK == BASE`` identifies an attribute byte
281
+ NON_DISPLAY_MASK = 0x07 # ``byte & MASK == 0x07`` means non-display
251
282
 
252
283
 
253
284
  # ---------------------------------------------------------------------------
@@ -436,6 +467,10 @@ SCREEN_COLS = 80
436
467
  SCREEN_ROWS_27 = 27 # 27×132 model
437
468
  SCREEN_COLS_132 = 132
438
469
 
470
+ # Length of the screen identifier the workstation returns in its Save Screen
471
+ # acknowledgment and that the host echoes back in a Restore Screen command.
472
+ SAVE_SCREEN_ID_LEN = 2
473
+
439
474
  EBCDIC_SPACE = 0x40
440
475
  EBCDIC_NULL = 0x00
441
476
 
@@ -541,10 +576,17 @@ def decode_addr(hi: int, lo: int, cols: int = SCREEN_COLS) -> int:
541
576
 
542
577
 
543
578
  def pos_to_rowcol(pos: int, cols: int = SCREEN_COLS) -> tuple[int, int]:
544
- """Convert a linear position to (row, col), both 0-based."""
545
- return divmod(pos, cols)
579
+ """Convert a zero-based linear position to a 1-based ``(row, col)``."""
580
+ if pos < 0:
581
+ raise ValueError("Buffer position must be non-negative")
582
+ row, col = divmod(pos, cols)
583
+ return row + 1, col + 1
546
584
 
547
585
 
548
586
  def rowcol_to_pos(row: int, col: int, cols: int = SCREEN_COLS) -> int:
549
- """Convert (row, col) to a linear position (both 0-based)."""
550
- return row * cols + col
587
+ """Convert a 1-based ``(row, col)`` to a zero-based linear position."""
588
+ if row < 1 or col < 1:
589
+ raise ValueError("Screen coordinates start at (1, 1)")
590
+ if col > cols:
591
+ raise ValueError(f"Column must be between 1 and {cols}")
592
+ return (row - 1) * cols + col - 1