ibm5250 0.1.0.dev5__tar.gz → 0.1.0.dev6__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.
- {ibm5250-0.1.0.dev5 → ibm5250-0.1.0.dev6}/PKG-INFO +1 -1
- {ibm5250-0.1.0.dev5 → ibm5250-0.1.0.dev6}/pyproject.toml +1 -1
- {ibm5250-0.1.0.dev5 → ibm5250-0.1.0.dev6}/src/ibm5250/connection.py +6 -14
- {ibm5250-0.1.0.dev5 → ibm5250-0.1.0.dev6}/src/ibm5250/constants.py +4 -4
- {ibm5250-0.1.0.dev5 → ibm5250-0.1.0.dev6}/src/ibm5250/data-stream-logic.md +14 -21
- {ibm5250-0.1.0.dev5 → ibm5250-0.1.0.dev6}/src/ibm5250/datastream.py +76 -69
- {ibm5250-0.1.0.dev5 → ibm5250-0.1.0.dev6}/src/ibm5250/screen.py +28 -67
- {ibm5250-0.1.0.dev5 → ibm5250-0.1.0.dev6}/src/ibm5250/terminal.py +7 -34
- {ibm5250-0.1.0.dev5 → ibm5250-0.1.0.dev6}/uv.lock +1 -1
- {ibm5250-0.1.0.dev5 → ibm5250-0.1.0.dev6}/.github/copilot-instructions.md +0 -0
- {ibm5250-0.1.0.dev5 → ibm5250-0.1.0.dev6}/.github/workflows/README.md +0 -0
- {ibm5250-0.1.0.dev5 → ibm5250-0.1.0.dev6}/.github/workflows/publish.yml +0 -0
- {ibm5250-0.1.0.dev5 → ibm5250-0.1.0.dev6}/.gitignore +0 -0
- {ibm5250-0.1.0.dev5 → ibm5250-0.1.0.dev6}/LICENSE +0 -0
- {ibm5250-0.1.0.dev5 → ibm5250-0.1.0.dev6}/README.md +0 -0
- {ibm5250-0.1.0.dev5 → ibm5250-0.1.0.dev6}/scripts/compute_version.py +0 -0
- {ibm5250-0.1.0.dev5 → ibm5250-0.1.0.dev6}/src/ibm5250/__init__.py +0 -0
- {ibm5250-0.1.0.dev5 → ibm5250-0.1.0.dev6}/src/ibm5250/exceptions.py +0 -0
|
@@ -33,7 +33,6 @@ from .constants import (
|
|
|
33
33
|
TN5250EDataType,
|
|
34
34
|
TN5250ERecordType,
|
|
35
35
|
TN5250ESubNeg,
|
|
36
|
-
encode_addr,
|
|
37
36
|
)
|
|
38
37
|
from .exceptions import ConnectionError, ProtocolError, RecvTimeout
|
|
39
38
|
|
|
@@ -49,6 +48,8 @@ _TELNET_OPT_CMD_LEN = 3 # bytes: IAC + command + option (WILL/WONT/DO/DONT)
|
|
|
49
48
|
# SNA / TN5250E wire-format framing constants
|
|
50
49
|
_GDS_RECORD_ID_HI = 0x12 # SNA GDS record-type identifier, high byte
|
|
51
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
|
|
52
53
|
_SAVE_SCREEN_RESP_CC1 = 0x01 # CC1 value in the Save Screen response variable header
|
|
53
54
|
|
|
54
55
|
# TN5250E header byte 7 (request/response flags). Real emulators set bit 0x40
|
|
@@ -81,12 +82,10 @@ class TN5250EHeader:
|
|
|
81
82
|
err_flag: int = 0x00
|
|
82
83
|
|
|
83
84
|
aid: int = AID.NO_AID # AID byte placed in the variable header (default: no-AID)
|
|
84
|
-
cursor_row: int = 0x00
|
|
85
|
-
cursor_col: int = 0x00
|
|
86
85
|
|
|
87
86
|
def to_bytes(self, payload_len: int) -> bytes:
|
|
88
|
-
# 3-byte variable header:
|
|
89
|
-
var_hdr = 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])
|
|
90
89
|
var_len = len(var_hdr)
|
|
91
90
|
total = payload_len + _TN5250E_HEADER_LEN + var_len
|
|
92
91
|
return (
|
|
@@ -239,8 +238,6 @@ class TN5250Connection:
|
|
|
239
238
|
*,
|
|
240
239
|
data_type: int = TN5250EDataType.INPUT,
|
|
241
240
|
aid: int = AID.NO_AID,
|
|
242
|
-
cursor_pos: int | None = None,
|
|
243
|
-
cols: int = 80,
|
|
244
241
|
) -> None:
|
|
245
242
|
"""Send a 5250 data record to the host.
|
|
246
243
|
|
|
@@ -251,21 +248,16 @@ class TN5250Connection:
|
|
|
251
248
|
raise ConnectionError("Not connected")
|
|
252
249
|
|
|
253
250
|
escaped = _iac_escape(data)
|
|
254
|
-
cursor_row, cursor_col = (
|
|
255
|
-
encode_addr(cursor_pos, cols) if cursor_pos is not None else (0, 0)
|
|
256
|
-
)
|
|
257
251
|
|
|
258
252
|
if self._tn5250e_active:
|
|
259
253
|
hdr = TN5250EHeader(
|
|
260
254
|
data_type=data_type,
|
|
261
255
|
aid=aid,
|
|
262
|
-
cursor_row=cursor_row,
|
|
263
|
-
cursor_col=cursor_col,
|
|
264
256
|
).to_bytes(len(escaped))
|
|
265
257
|
frame = hdr + escaped
|
|
266
258
|
else:
|
|
267
|
-
# Basic TN5250
|
|
268
|
-
frame = bytes([aid
|
|
259
|
+
# Basic TN5250: AID byte is the first byte of the payload
|
|
260
|
+
frame = bytes([aid]) + escaped
|
|
269
261
|
|
|
270
262
|
frame += bytes([TelnetCmd.INTERPRET_AS_COMMAND, TelnetCmd.END_OF_RECORD])
|
|
271
263
|
|
|
@@ -87,7 +87,6 @@ class Command:
|
|
|
87
87
|
0x02 # Introduces an inline WSF record within a WTD stream
|
|
88
88
|
)
|
|
89
89
|
ESC = 0x04 # precedes Write Structured Field inline
|
|
90
|
-
CLEAR_UNIT = 0x40 # erase the display and format table
|
|
91
90
|
WRITE_TO_DISPLAY = (
|
|
92
91
|
0x11 # main screen update command; followed by CC1/CC2 and orders
|
|
93
92
|
)
|
|
@@ -122,10 +121,11 @@ class CC2:
|
|
|
122
121
|
"""Control Character 2 — second byte after WTD command."""
|
|
123
122
|
|
|
124
123
|
RESET = 0x00
|
|
125
|
-
UNLOCK_KEYBOARD =
|
|
126
|
-
RESET_MDT =
|
|
127
|
-
RESET_MDT_UNLOCK =
|
|
124
|
+
UNLOCK_KEYBOARD = 0x01
|
|
125
|
+
RESET_MDT = 0x02 # Clear all MDT bits
|
|
126
|
+
RESET_MDT_UNLOCK = 0x03
|
|
128
127
|
SET_MDT = 0x04 # Set MDT on all input fields
|
|
128
|
+
MOVE_CURSOR = 0x08 # IC order sets cursor, not default position
|
|
129
129
|
|
|
130
130
|
|
|
131
131
|
# ---------------------------------------------------------------------------
|
|
@@ -24,8 +24,7 @@ The **TN5250Connection** manages the TCP socket to the IBM i (AS/400) host.
|
|
|
24
24
|
|
|
25
25
|
### Sending (`send_record`)
|
|
26
26
|
|
|
27
|
-
1. Takes raw 5250 payload bytes.
|
|
28
|
-
variable header contains the cursor row, cursor column, and AID.
|
|
27
|
+
1. Takes raw 5250 payload bytes.
|
|
29
28
|
2. IAC-escapes them (any 0xFF in the data becomes 0xFF 0xFF).
|
|
30
29
|
3. Prepends the TN5250E header (with data-type and AID byte) if in enhanced mode.
|
|
31
30
|
4. Appends IAC-EOR terminator.
|
|
@@ -45,17 +44,16 @@ The **DataStreamParser** takes a raw 5250 payload (one record from the connectio
|
|
|
45
44
|
|
|
46
45
|
Every 5250 record starts with a **command byte**:
|
|
47
46
|
|
|
48
|
-
| Command | Meaning
|
|
49
|
-
| ------------------------------- |
|
|
50
|
-
| `0x11` (Write to Display) | Main screen update — followed by CC1, CC2, then orders/data
|
|
51
|
-
| `
|
|
52
|
-
| `
|
|
53
|
-
| `
|
|
54
|
-
| `
|
|
55
|
-
| `
|
|
56
|
-
| `
|
|
57
|
-
| `
|
|
58
|
-
| `0x50` (Clear Format Table) | Discard field definitions |
|
|
47
|
+
| Command | Meaning |
|
|
48
|
+
| ------------------------------- | ----------------------------------------------------------------------- |
|
|
49
|
+
| `0x11` (Write to Display) | Main screen update — followed by CC1, CC2, then orders/data |
|
|
50
|
+
| `0x02` (Save Screen) | Server wants to save the current screen (same format as WTD) |
|
|
51
|
+
| `0x12` (Restore Screen) | Server wants to restore a previously saved screen |
|
|
52
|
+
| `0x04` (ESC) | Escape prefix — next byte is the real command (WTD, WSF, Save, Restore) |
|
|
53
|
+
| `0xF3` (Write Structured Field) | Carries structured data like Query commands |
|
|
54
|
+
| `0x42/0x52/0x72/0x62` | Read commands — server asks client to send back screen/field data |
|
|
55
|
+
| `0x21` (Write Error Code) | Display error on status line |
|
|
56
|
+
| `0x50` (Clear Format Table) | Discard field definitions |
|
|
59
57
|
|
|
60
58
|
### Control Characters (CC1 / CC2)
|
|
61
59
|
|
|
@@ -67,9 +65,9 @@ After a WTD or Save Screen command, two bytes control screen-level behavior:
|
|
|
67
65
|
- `0x00`: Don't clear — overlay on existing content
|
|
68
66
|
|
|
69
67
|
- **CC2** — Keyboard and field flags:
|
|
70
|
-
- `
|
|
71
|
-
- `
|
|
72
|
-
- `
|
|
68
|
+
- `0x01`: Unlock the keyboard (let the user type)
|
|
69
|
+
- `0x02`: Reset all Modified Data Tags (MDT) on fields
|
|
70
|
+
- `0x03`: Both unlock + reset MDT
|
|
73
71
|
|
|
74
72
|
### Orders (the body of a WTD)
|
|
75
73
|
|
|
@@ -148,11 +146,6 @@ The **Screen** holds the actual state that represents what you'd see on a physic
|
|
|
148
146
|
- `find_field_by_label(label)` — finds the first input field immediately after the given label text
|
|
149
147
|
- `get_field_text(field)` — reads the EBCDIC content of a specific field and decodes it
|
|
150
148
|
|
|
151
|
-
Display extraction honors 5250 non-display attributes. Text following a
|
|
152
|
-
non-display attribute byte is blanked until the next display attribute, and
|
|
153
|
-
fields carrying a non-display FCW (such as password fields) remain readable
|
|
154
|
-
through field APIs but are omitted from rendered screen text.
|
|
155
|
-
|
|
156
149
|
---
|
|
157
150
|
|
|
158
151
|
## 4. Terminal Layer (`terminal.py`)
|
|
@@ -36,6 +36,7 @@ from dataclasses import dataclass, field
|
|
|
36
36
|
from enum import Enum, auto
|
|
37
37
|
|
|
38
38
|
from .constants import (
|
|
39
|
+
_KNOWN_ORDERS,
|
|
39
40
|
AID,
|
|
40
41
|
CC1,
|
|
41
42
|
EBCDIC_NULL,
|
|
@@ -86,7 +87,6 @@ class DSEvent:
|
|
|
86
87
|
data: bytes = b"" # EBCDIC bytes (WRITE_CHARS, REPEAT_TO char, …)
|
|
87
88
|
field_format_word: bytes = b"" # Field Format Word 2-byte tuple (START_FIELD)
|
|
88
89
|
field_control_words: list[bytes] = field(default_factory=list) # optional FCW pairs
|
|
89
|
-
field_length: int = 0 # explicit field length supplied by an FCW
|
|
90
90
|
attribute_type: int = 0 # SA attribute type
|
|
91
91
|
attribute_value: int = 0 # SA attribute value
|
|
92
92
|
command: int = 0 # raw command byte (COMMAND event)
|
|
@@ -208,20 +208,8 @@ class DataStreamParser:
|
|
|
208
208
|
byte_offset,
|
|
209
209
|
data_view[byte_offset] if byte_offset < total_bytes else 0xFF,
|
|
210
210
|
)
|
|
211
|
-
clear_unit = (
|
|
212
|
-
byte_offset < total_bytes
|
|
213
|
-
and data_view[byte_offset] == Command.CLEAR_UNIT
|
|
214
|
-
)
|
|
215
|
-
if clear_unit:
|
|
216
|
-
byte_offset += 1
|
|
217
|
-
yield DSEvent(
|
|
218
|
-
type=DSEventType.COMMAND,
|
|
219
|
-
command=Command.CLEAR_UNIT,
|
|
220
|
-
)
|
|
221
|
-
yield DSEvent(type=DSEventType.CLEAR_SCREEN)
|
|
222
211
|
if (
|
|
223
|
-
|
|
224
|
-
and byte_offset < total_bytes
|
|
212
|
+
byte_offset < total_bytes
|
|
225
213
|
and data_view[byte_offset] == Command.WRITE_STRUCTURED_FIELD
|
|
226
214
|
):
|
|
227
215
|
# ESC 0xF3 — Write Structured Field
|
|
@@ -234,7 +222,7 @@ class DataStreamParser:
|
|
|
234
222
|
if byte_offset < total_bytes and data_view[byte_offset] in (
|
|
235
223
|
Command.SAVE_SCREEN,
|
|
236
224
|
Command.RESTORE_SCREEN,
|
|
237
|
-
)
|
|
225
|
+
):
|
|
238
226
|
sub_command = data_view[byte_offset]
|
|
239
227
|
byte_offset += 1
|
|
240
228
|
log.debug(
|
|
@@ -244,17 +232,14 @@ class DataStreamParser:
|
|
|
244
232
|
return
|
|
245
233
|
# ESC + WTD (0x11): explicit Write to Display
|
|
246
234
|
if (
|
|
247
|
-
|
|
248
|
-
and byte_offset < total_bytes
|
|
235
|
+
byte_offset < total_bytes
|
|
249
236
|
and data_view[byte_offset] == Command.WRITE_TO_DISPLAY
|
|
250
237
|
):
|
|
251
238
|
byte_offset += 1 # consume the 0x11 WTD byte
|
|
252
239
|
# fall through to read CC1/CC2 below
|
|
253
240
|
# ESC used as an implicit WTD command: next two bytes are CC1/CC2.
|
|
254
241
|
# IBM i sometimes sends 0x04 CC1 CC2 orders instead of 0x11 CC1 CC2.
|
|
255
|
-
if
|
|
256
|
-
pass
|
|
257
|
-
elif byte_offset + 2 > total_bytes:
|
|
242
|
+
if byte_offset + 2 > total_bytes:
|
|
258
243
|
# Only one byte remains — treat it as a standalone CC1 (clear command)
|
|
259
244
|
if byte_offset + 1 <= total_bytes:
|
|
260
245
|
cc1 = data_view[byte_offset]
|
|
@@ -273,33 +258,32 @@ class DataStreamParser:
|
|
|
273
258
|
else:
|
|
274
259
|
log.debug("ESC with insufficient bytes for CC1/CC2 — skipping")
|
|
275
260
|
return
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
)
|
|
295
|
-
|
|
296
|
-
if
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
yield DSEvent(type=DSEventType.RESIZE_SCREEN, rows=alt_rows, cols=alt_cols)
|
|
261
|
+
cc1 = data_view[byte_offset]
|
|
262
|
+
cc2 = data_view[byte_offset + 1]
|
|
263
|
+
byte_offset += 2
|
|
264
|
+
log.debug(
|
|
265
|
+
"ESC treated as implicit WTD: CC1=0x%02X CC2=0x%02X", cc1, cc2
|
|
266
|
+
)
|
|
267
|
+
yield DSEvent(
|
|
268
|
+
type=DSEventType.COMMAND,
|
|
269
|
+
command=command_byte,
|
|
270
|
+
control_char_1=cc1,
|
|
271
|
+
control_char_2=cc2,
|
|
272
|
+
)
|
|
273
|
+
cc1_clear_flags = cc1 & (CC1.CLEAR_UNIT | CC1.CLEAR_UNIT_ALT)
|
|
274
|
+
if cc1_clear_flags in (
|
|
275
|
+
CC1.CLEAR_UNIT,
|
|
276
|
+
CC1.CLEAR_UNIT_ALT,
|
|
277
|
+
CC1.CLEAR_FORMAT_TABLE,
|
|
278
|
+
): # Clear Unit, Clear Unit Alternate, or Clear Format Table
|
|
279
|
+
yield DSEvent(type=DSEventType.CLEAR_SCREEN)
|
|
280
|
+
if cc1_clear_flags == CC1.CLEAR_UNIT_ALT:
|
|
281
|
+
alt_rows = SCREEN_ROWS if self.rows == SCREEN_ROWS_27 else SCREEN_ROWS_27
|
|
282
|
+
alt_cols = SCREEN_COLS if self.cols == SCREEN_COLS_132 else SCREEN_COLS_132
|
|
283
|
+
self.rows = alt_rows
|
|
284
|
+
self.cols = alt_cols
|
|
285
|
+
self._size = alt_rows * alt_cols
|
|
286
|
+
yield DSEvent(type=DSEventType.RESIZE_SCREEN, rows=alt_rows, cols=alt_cols)
|
|
303
287
|
|
|
304
288
|
case _:
|
|
305
289
|
# Unrecognised command — treat remainder as orders/data
|
|
@@ -473,7 +457,6 @@ class DataStreamParser:
|
|
|
473
457
|
# Collect optional Field Control Word (FCW) pairs (2 bytes each).
|
|
474
458
|
# FCW pairs follow until we encounter a known order byte or run out of data.
|
|
475
459
|
field_control_words: list[bytes] = []
|
|
476
|
-
field_length = 0
|
|
477
460
|
while byte_offset + 2 <= total_bytes:
|
|
478
461
|
next_byte = data_view[byte_offset]
|
|
479
462
|
# Orders that can immediately follow an SF/MF — stop collecting FCWs
|
|
@@ -498,10 +481,17 @@ class DataStreamParser:
|
|
|
498
481
|
)
|
|
499
482
|
byte_offset += 2
|
|
500
483
|
field_control_words.append(field_control_word)
|
|
501
|
-
|
|
502
|
-
if
|
|
503
|
-
|
|
504
|
-
|
|
484
|
+
# FCW type 0x22 carries an additional field-length byte
|
|
485
|
+
if (field_control_word[0] & 0x7F) == 0x22 and byte_offset < total_bytes:
|
|
486
|
+
byte_offset += 1 # skip field length byte
|
|
487
|
+
# FCW type 0x26 (resequencing) carries an additional byte;
|
|
488
|
+
# only skip it if the next byte is an inline WSF/ESC marker
|
|
489
|
+
elif (field_control_word[0] & 0x7F) == 0x26 and byte_offset < total_bytes:
|
|
490
|
+
next_byte_val = data_view[byte_offset]
|
|
491
|
+
if next_byte_val not in (
|
|
492
|
+
Command.INLINE_WRITE_STRUCTURED_FIELD, Command.ESC
|
|
493
|
+
):
|
|
494
|
+
byte_offset += 1 # skip resequencing extra byte
|
|
505
495
|
# If bit 15 of the FCW is 0, this is the last FCW in the chain
|
|
506
496
|
if not (field_control_word[0] & FCW.CONTINUATION_BIT):
|
|
507
497
|
break
|
|
@@ -510,26 +500,34 @@ class DataStreamParser:
|
|
|
510
500
|
position=current_buffer_position,
|
|
511
501
|
field_format_word=field_format_word,
|
|
512
502
|
field_control_words=field_control_words,
|
|
513
|
-
field_length=field_length,
|
|
514
503
|
)
|
|
515
504
|
yield ev
|
|
516
505
|
current_buffer_position = (
|
|
517
506
|
current_buffer_position + 1
|
|
518
507
|
) % self._size # attribute byte consumes one position
|
|
519
508
|
|
|
520
|
-
# --- Inline
|
|
509
|
+
# --- Inline Write Structured Field (0x02) / ESC (0x04) ---
|
|
510
|
+
# IBM i embeds 0x02 XX YY sequences in text to carry colour/attribute overrides. Each marker is exactly 3 bytes.
|
|
511
|
+
# 0x04 (ESC) in the order stream also introduces structured fields.
|
|
521
512
|
case Command.INLINE_WRITE_STRUCTURED_FIELD | Command.ESC:
|
|
522
513
|
next_byte = data_view[byte_offset + 1] if byte_offset + 1 < total_bytes else 0xFF
|
|
523
|
-
if
|
|
524
|
-
|
|
514
|
+
third_byte = data_view[byte_offset + 2] if byte_offset + 2 < total_bytes else 0xFF
|
|
515
|
+
# Disambiguate: 0x04 0x11 could be ESC+WTD or data+SBA.
|
|
516
|
+
# If the third byte is a plausible 1-based SBA row address (0x01–0x1B),
|
|
517
|
+
# this is a data byte followed by an SBA order — not ESC+WTD.
|
|
518
|
+
# Row 0x00 is invalid for SBA (1-based encoding), so that case
|
|
519
|
+
# falls through to the ESC+WTD handler.
|
|
520
|
+
if next_byte == Order.SET_BUFFER_ADDRESS and 0x01 <= third_byte <= 0x1B:
|
|
521
|
+
# byte[+1] is a real order and byte[+2] is a plausible operand
|
|
522
|
+
# → 0x02/0x04 is just a data byte, not a WSF marker
|
|
523
|
+
byte_offset += 1
|
|
525
524
|
# ESC + WTD (0x04 0x11): embedded Write to Display sub-command
|
|
526
|
-
elif next_byte == Command.WRITE_TO_DISPLAY:
|
|
525
|
+
elif current_byte == Command.ESC and next_byte == Command.WRITE_TO_DISPLAY:
|
|
527
526
|
if byte_offset + 4 > total_bytes:
|
|
528
527
|
break
|
|
529
528
|
cc1 = data_view[byte_offset + 2]
|
|
530
529
|
cc2 = data_view[byte_offset + 3]
|
|
531
530
|
byte_offset += 4 # consume ESC + WTD + CC1 + CC2
|
|
532
|
-
current_buffer_position = 0
|
|
533
531
|
log.debug(
|
|
534
532
|
"Inline ESC+WTD: CC1=0x%02X CC2=0x%02X", cc1, cc2
|
|
535
533
|
)
|
|
@@ -549,8 +547,18 @@ class DataStreamParser:
|
|
|
549
547
|
self.cols = alt_cols
|
|
550
548
|
self._size = alt_rows * alt_cols
|
|
551
549
|
yield DSEvent(type=DSEventType.RESIZE_SCREEN, rows=alt_rows, cols=alt_cols)
|
|
550
|
+
elif current_byte == Command.INLINE_WRITE_STRUCTURED_FIELD and next_byte == 0x00:
|
|
551
|
+
# 0x02 0x00 0x00 XX — consume 3 if the 4th byte is a structural order
|
|
552
|
+
# (preserving the order), otherwise consume 4 (the 4th byte is trailing padding)
|
|
553
|
+
fourth_byte = data_view[byte_offset + 3] if byte_offset + 3 < total_bytes else 0xFF
|
|
554
|
+
if fourth_byte in _KNOWN_ORDERS or fourth_byte in (
|
|
555
|
+
Command.INLINE_WRITE_STRUCTURED_FIELD, Command.ESC
|
|
556
|
+
):
|
|
557
|
+
byte_offset += 3
|
|
558
|
+
else:
|
|
559
|
+
byte_offset += 4 if byte_offset + 4 <= total_bytes else (total_bytes - byte_offset)
|
|
552
560
|
else:
|
|
553
|
-
#
|
|
561
|
+
# Real inline WSF attribute marker (4 bytes: marker + type + value + trailing)
|
|
554
562
|
byte_offset += 4 if byte_offset + 4 <= total_bytes else 3
|
|
555
563
|
|
|
556
564
|
# --- SET_ATTRIBUTE ---
|
|
@@ -687,8 +695,6 @@ def build_aid_response(
|
|
|
687
695
|
cursor_pos: int,
|
|
688
696
|
fields: list[FieldResponse],
|
|
689
697
|
cols: int = SCREEN_COLS,
|
|
690
|
-
*,
|
|
691
|
-
include_cursor: bool = True,
|
|
692
698
|
) -> bytes:
|
|
693
699
|
"""Build the workstation→host input response record payload.
|
|
694
700
|
|
|
@@ -711,16 +717,10 @@ def build_aid_response(
|
|
|
711
717
|
bytes
|
|
712
718
|
Raw 5250 payload ready for :meth:`TN5250Connection.send_record`.
|
|
713
719
|
"""
|
|
714
|
-
response_payload = bytearray()
|
|
715
|
-
if include_cursor and not fields:
|
|
716
|
-
address_high, address_low = encode_addr(cursor_pos, cols)
|
|
717
|
-
response_payload += bytes(
|
|
718
|
-
[Order.SET_BUFFER_ADDRESS, address_high, address_low]
|
|
719
|
-
)
|
|
720
|
-
|
|
721
720
|
if fields:
|
|
721
|
+
# The cursor SBA doubles as the first field's SBA — no separate entry needed.
|
|
722
722
|
address_high, address_low = encode_addr(fields[0].buffer_pos, cols)
|
|
723
|
-
response_payload
|
|
723
|
+
response_payload = bytearray(
|
|
724
724
|
[Order.SET_BUFFER_ADDRESS, address_high, address_low]
|
|
725
725
|
)
|
|
726
726
|
response_payload += fields[0].data.rstrip(bytes([EBCDIC_SPACE, EBCDIC_NULL]))
|
|
@@ -734,6 +734,13 @@ def build_aid_response(
|
|
|
734
734
|
response_payload += field_response.data.rstrip(
|
|
735
735
|
bytes([EBCDIC_SPACE, EBCDIC_NULL])
|
|
736
736
|
)
|
|
737
|
+
else:
|
|
738
|
+
# No modified fields — send cursor position only.
|
|
739
|
+
address_high, address_low = encode_addr(cursor_pos, cols)
|
|
740
|
+
response_payload = bytearray(
|
|
741
|
+
[Order.SET_BUFFER_ADDRESS, address_high, address_low]
|
|
742
|
+
)
|
|
743
|
+
|
|
737
744
|
return bytes(response_payload)
|
|
738
745
|
|
|
739
746
|
|
|
@@ -19,7 +19,6 @@ from collections.abc import Sequence
|
|
|
19
19
|
from dataclasses import dataclass, field
|
|
20
20
|
|
|
21
21
|
from .constants import (
|
|
22
|
-
CC2,
|
|
23
22
|
EBCDIC_NULL,
|
|
24
23
|
EBCDIC_SPACE,
|
|
25
24
|
FFW,
|
|
@@ -65,7 +64,6 @@ class Field:
|
|
|
65
64
|
False # Modified Data Tag — set when user edits the field
|
|
66
65
|
)
|
|
67
66
|
_saved_data: bytes = b"" # original content before user modification
|
|
68
|
-
_explicit_length: int = 0
|
|
69
67
|
|
|
70
68
|
# ---- derived properties ----
|
|
71
69
|
|
|
@@ -92,16 +90,7 @@ class Field:
|
|
|
92
90
|
@property
|
|
93
91
|
def is_non_display(self) -> bool:
|
|
94
92
|
"""True when the field is non-display (password entry)."""
|
|
95
|
-
return (
|
|
96
|
-
(self.field_format_word[1] & FFW.DISPLAY_ATTR_MASK) == FFW.NON_DISPLAY
|
|
97
|
-
or (
|
|
98
|
-
self.field_format_word[1] == 0x00
|
|
99
|
-
and any(
|
|
100
|
-
(fcw[0] & 0x7F) == 0x27
|
|
101
|
-
for fcw in self.field_control_words
|
|
102
|
-
)
|
|
103
|
-
)
|
|
104
|
-
)
|
|
93
|
+
return (self.field_format_word[1] & 0xE0) == FFW.NON_DISPLAY
|
|
105
94
|
|
|
106
95
|
@property
|
|
107
96
|
def mdt(self) -> bool:
|
|
@@ -200,7 +189,7 @@ class Screen:
|
|
|
200
189
|
|
|
201
190
|
elif event_type == DSEventType.COMMAND:
|
|
202
191
|
cc2 = event.control_char_2
|
|
203
|
-
if cc2 &
|
|
192
|
+
if cc2 & 0x02: # reset MDT bits on all fields
|
|
204
193
|
for screen_field in self._fields:
|
|
205
194
|
screen_field.mdt = False
|
|
206
195
|
|
|
@@ -212,6 +201,17 @@ class Screen:
|
|
|
212
201
|
|
|
213
202
|
elif event_type == DSEventType.WRITE_CHARS:
|
|
214
203
|
self._write(event.position, event.data)
|
|
204
|
+
# Clear ghost tail when a shorter value overwrites a longer one
|
|
205
|
+
pos = event.position
|
|
206
|
+
new_len = len(event.data)
|
|
207
|
+
prev_len = self._write_lengths.get(pos, 0)
|
|
208
|
+
if prev_len > new_len:
|
|
209
|
+
tail_start = pos + new_len
|
|
210
|
+
tail_end = min(pos + prev_len, self._size)
|
|
211
|
+
self._chars[tail_start:tail_end] = (
|
|
212
|
+
bytes([EBCDIC_SPACE]) * (tail_end - tail_start)
|
|
213
|
+
)
|
|
214
|
+
self._write_lengths[pos] = new_len
|
|
215
215
|
|
|
216
216
|
elif event_type == DSEventType.ERASE_TO:
|
|
217
217
|
self._fill_range(event.position, event.attribute_value, EBCDIC_NULL)
|
|
@@ -236,10 +236,7 @@ class Screen:
|
|
|
236
236
|
pre_existing_field_rows: set[int] = {f.start_pos // self.cols for f in self._fields}
|
|
237
237
|
|
|
238
238
|
for ev in events:
|
|
239
|
-
if
|
|
240
|
-
ev.type == DSEventType.COMMAND
|
|
241
|
-
and ev.control_char_2 & CC2.UNLOCK_KEYBOARD
|
|
242
|
-
):
|
|
239
|
+
if ev.type == DSEventType.COMMAND and ev.control_char_2 & 0x01:
|
|
243
240
|
deferred_unlock = True
|
|
244
241
|
if ev.type == DSEventType.CLEAR_SCREEN:
|
|
245
242
|
has_clear = True
|
|
@@ -311,14 +308,23 @@ class Screen:
|
|
|
311
308
|
zero_based_row = row - 1
|
|
312
309
|
zero_based_col = col - 1
|
|
313
310
|
buffer_pos = rowcol_to_pos(zero_based_row, zero_based_col, self.cols)
|
|
314
|
-
|
|
315
|
-
|
|
311
|
+
raw_bytes = bytes(self._chars[buffer_pos : buffer_pos + length])
|
|
312
|
+
if len(raw_bytes) < length:
|
|
313
|
+
# Wrap around to the beginning of the buffer
|
|
314
|
+
raw_bytes += bytes(self._chars[: length - len(raw_bytes)])
|
|
315
|
+
# Replace attribute bytes (< 0x40) with EBCDIC space so they display as blank
|
|
316
|
+
display_bytes = bytes(b if b >= 0x40 else EBCDIC_SPACE for b in raw_bytes)
|
|
317
|
+
return ebcdic_decode(display_bytes)
|
|
316
318
|
|
|
317
319
|
def get_row(self, row: int) -> str:
|
|
318
320
|
"""Return the full text of *row* (0-based) as a Python string."""
|
|
319
321
|
row_start_pos = row * self.cols
|
|
320
|
-
|
|
321
|
-
|
|
322
|
+
row_bytes = self._chars[row_start_pos : row_start_pos + self.cols]
|
|
323
|
+
# Bytes 0x00–0x3F are IBM 5250 field/character attribute cells, not
|
|
324
|
+
# displayable characters. Replace them with EBCDIC space (0x40) so
|
|
325
|
+
# they render as blanks rather than as garbage CP037 glyphs.
|
|
326
|
+
display_bytes = bytes(b if b >= 0x40 else EBCDIC_SPACE for b in row_bytes)
|
|
327
|
+
return ebcdic_decode(display_bytes)
|
|
322
328
|
|
|
323
329
|
def get_all_rows(self) -> list[str]:
|
|
324
330
|
"""Return all rows as a list of strings."""
|
|
@@ -359,15 +365,7 @@ class Screen:
|
|
|
359
365
|
f._saved_data = bytes(self._chars[pos : pos + write_len])
|
|
360
366
|
self._chars[pos : pos + len(encoded)] = encoded
|
|
361
367
|
f.mdt = True
|
|
362
|
-
self._cursor =
|
|
363
|
-
if len(encoded) == write_len:
|
|
364
|
-
next_fields = [
|
|
365
|
-
candidate
|
|
366
|
-
for candidate in self.input_fields
|
|
367
|
-
if candidate.data_pos > f.data_pos
|
|
368
|
-
]
|
|
369
|
-
if next_fields:
|
|
370
|
-
self._cursor = min(next_fields, key=lambda candidate: candidate.data_pos).data_pos
|
|
368
|
+
self._cursor = pos # move cursor into the field
|
|
371
369
|
|
|
372
370
|
# ---- field lookups ----
|
|
373
371
|
|
|
@@ -584,38 +582,6 @@ class Screen:
|
|
|
584
582
|
self._write_lengths.clear()
|
|
585
583
|
self._keyboard_locked = True
|
|
586
584
|
|
|
587
|
-
def _display_bytes(self, positions: Sequence[int]) -> bytes:
|
|
588
|
-
"""Return displayable bytes, honoring non-display attributes and fields."""
|
|
589
|
-
position_list = list(positions)
|
|
590
|
-
if not position_list:
|
|
591
|
-
return b""
|
|
592
|
-
|
|
593
|
-
hidden = False
|
|
594
|
-
for byte in self._chars[: position_list[0]]:
|
|
595
|
-
if 0x20 <= byte <= 0x3F:
|
|
596
|
-
hidden = (byte & 0x07) == 0x07
|
|
597
|
-
|
|
598
|
-
non_display_positions = {
|
|
599
|
-
pos
|
|
600
|
-
for screen_field in self._fields
|
|
601
|
-
if screen_field.is_non_display
|
|
602
|
-
for pos in range(
|
|
603
|
-
screen_field.data_pos,
|
|
604
|
-
min(screen_field.data_pos + screen_field.length, self._size),
|
|
605
|
-
)
|
|
606
|
-
}
|
|
607
|
-
display = bytearray()
|
|
608
|
-
for pos in position_list:
|
|
609
|
-
byte = self._chars[pos]
|
|
610
|
-
if 0x20 <= byte <= 0x3F:
|
|
611
|
-
hidden = (byte & 0x07) == 0x07
|
|
612
|
-
display.append(EBCDIC_SPACE)
|
|
613
|
-
elif byte < 0x40 or hidden or pos in non_display_positions:
|
|
614
|
-
display.append(EBCDIC_SPACE)
|
|
615
|
-
else:
|
|
616
|
-
display.append(byte)
|
|
617
|
-
return bytes(display)
|
|
618
|
-
|
|
619
585
|
def _write(self, start: int, data: bytes) -> None:
|
|
620
586
|
end = min(start + len(data), self._size)
|
|
621
587
|
count = end - start
|
|
@@ -634,10 +600,8 @@ class Screen:
|
|
|
634
600
|
"""Add or update a field from an SF/MF event."""
|
|
635
601
|
new_field = Field(
|
|
636
602
|
start_pos=event.position,
|
|
637
|
-
length=event.field_length,
|
|
638
603
|
field_format_word=event.field_format_word,
|
|
639
604
|
field_control_words=event.field_control_words,
|
|
640
|
-
_explicit_length=event.field_length,
|
|
641
605
|
)
|
|
642
606
|
# Replace any existing field at this position (dedup, preserving order)
|
|
643
607
|
for i, existing in enumerate(self._fields):
|
|
@@ -657,9 +621,6 @@ class Screen:
|
|
|
657
621
|
if not fields:
|
|
658
622
|
return
|
|
659
623
|
for field_index, current_field in enumerate(fields):
|
|
660
|
-
if current_field._explicit_length:
|
|
661
|
-
current_field.length = current_field._explicit_length
|
|
662
|
-
continue
|
|
663
624
|
if field_index + 1 < len(fields):
|
|
664
625
|
next_field_start_pos = fields[field_index + 1].start_pos
|
|
665
626
|
else:
|
|
@@ -156,7 +156,6 @@ class Terminal:
|
|
|
156
156
|
self._cols = cols
|
|
157
157
|
self._saved_screens: list[Screen] = []
|
|
158
158
|
self._save_pending: bool = False
|
|
159
|
-
self._screen_before_partial_update: Screen | None = None
|
|
160
159
|
|
|
161
160
|
# ------------------------------------------------------------------
|
|
162
161
|
# Connection management
|
|
@@ -210,8 +209,6 @@ class Terminal:
|
|
|
210
209
|
wait:
|
|
211
210
|
Block until a screen update is received.
|
|
212
211
|
"""
|
|
213
|
-
if self._screen.keyboard_locked:
|
|
214
|
-
self.wait_for_keyboard_unlock()
|
|
215
212
|
self._send_aid(aid)
|
|
216
213
|
if wait:
|
|
217
214
|
self._recv_and_apply()
|
|
@@ -523,18 +520,9 @@ class Terminal:
|
|
|
523
520
|
for modified_field in modified_fields
|
|
524
521
|
]
|
|
525
522
|
aid_payload = build_aid_response(
|
|
526
|
-
self._screen.cursor_pos,
|
|
527
|
-
field_responses,
|
|
528
|
-
cols=self._screen.cols,
|
|
529
|
-
include_cursor=False,
|
|
530
|
-
)
|
|
531
|
-
self._conn.send_record(
|
|
532
|
-
aid_payload,
|
|
533
|
-
data_type=TN5250EDataType.INPUT,
|
|
534
|
-
aid=aid,
|
|
535
|
-
cursor_pos=self._screen.cursor_pos,
|
|
536
|
-
cols=self._screen.cols,
|
|
523
|
+
self._screen.cursor_pos, field_responses, cols=self._screen.cols
|
|
537
524
|
)
|
|
525
|
+
self._conn.send_record(aid_payload, data_type=TN5250EDataType.INPUT, aid=aid)
|
|
538
526
|
self._screen.clear_modified_fields()
|
|
539
527
|
self._screen._keyboard_locked = True
|
|
540
528
|
if aid in (AID.PAGE_DOWN, AID.PAGE_UP):
|
|
@@ -565,9 +553,7 @@ class Terminal:
|
|
|
565
553
|
event.type == DSEventType.COMMAND
|
|
566
554
|
and event.command == Command.SAVE_SCREEN
|
|
567
555
|
):
|
|
568
|
-
|
|
569
|
-
self._saved_screens.append(screen_to_save.copy())
|
|
570
|
-
self._screen_before_partial_update = None
|
|
556
|
+
self._saved_screens.append(self._screen.copy())
|
|
571
557
|
self._save_pending = True
|
|
572
558
|
self._conn.send_save_screen_response()
|
|
573
559
|
elif (
|
|
@@ -575,12 +561,10 @@ class Terminal:
|
|
|
575
561
|
and event.command == Command.RESTORE_SCREEN
|
|
576
562
|
):
|
|
577
563
|
if self._saved_screens:
|
|
564
|
+
# Back-to-back SAVE+RESTORE: discard the redundant save
|
|
578
565
|
if self._save_pending and len(self._saved_screens) >= 2:
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
self._saved_screens.append(saved_current)
|
|
582
|
-
else:
|
|
583
|
-
self._screen = self._saved_screens.pop()
|
|
566
|
+
self._saved_screens.pop()
|
|
567
|
+
self._screen = self._saved_screens.pop()
|
|
584
568
|
self._screen.clear_modified_fields()
|
|
585
569
|
# If a CLEAR_UNIT_ALT resized the parser before restore,
|
|
586
570
|
# the restored screen dimensions won't match — resize preserving content
|
|
@@ -622,6 +606,7 @@ class Terminal:
|
|
|
622
606
|
if restore_index is not None:
|
|
623
607
|
post_events = parsed_events[restore_index + 1:]
|
|
624
608
|
self._screen.apply_all(post_events)
|
|
609
|
+
print(f"RESTORE: screen text after apply_all:\n{self._screen.get_screen_text()}")
|
|
625
610
|
# After RESTORE, the saved screen has keyboard locked. The server
|
|
626
611
|
# sends a follow-up record to unlock it — consume it now to
|
|
627
612
|
# prevent out-of-turn AID sends.
|
|
@@ -631,18 +616,6 @@ class Terminal:
|
|
|
631
616
|
except RecvTimeout:
|
|
632
617
|
pass
|
|
633
618
|
else:
|
|
634
|
-
has_screen_content = any(
|
|
635
|
-
event.type not in (DSEventType.COMMAND, DSEventType.QUERY)
|
|
636
|
-
for event in parsed_events
|
|
637
|
-
)
|
|
638
|
-
if has_screen_content:
|
|
639
|
-
if any(
|
|
640
|
-
event.type == DSEventType.CLEAR_SCREEN
|
|
641
|
-
for event in parsed_events
|
|
642
|
-
):
|
|
643
|
-
self._screen_before_partial_update = None
|
|
644
|
-
else:
|
|
645
|
-
self._screen_before_partial_update = self._screen.copy()
|
|
646
619
|
self._screen.apply_all(parsed_events)
|
|
647
620
|
|
|
648
621
|
def _send_query_reply(self) -> None:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|