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