ibm5250 0.1.0.dev8__tar.gz → 0.1.0.dev10__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.dev8 → ibm5250-0.1.0.dev10}/PKG-INFO +1 -1
- {ibm5250-0.1.0.dev8 → ibm5250-0.1.0.dev10}/pyproject.toml +1 -1
- {ibm5250-0.1.0.dev8 → ibm5250-0.1.0.dev10}/src/ibm5250/__init__.py +6 -0
- {ibm5250-0.1.0.dev8 → ibm5250-0.1.0.dev10}/src/ibm5250/connection.py +61 -21
- {ibm5250-0.1.0.dev8 → ibm5250-0.1.0.dev10}/src/ibm5250/data-stream-logic.md +29 -8
- {ibm5250-0.1.0.dev8 → ibm5250-0.1.0.dev10}/src/ibm5250/datastream.py +252 -17
- {ibm5250-0.1.0.dev8 → ibm5250-0.1.0.dev10}/src/ibm5250/screen.py +83 -21
- {ibm5250-0.1.0.dev8 → ibm5250-0.1.0.dev10}/src/ibm5250/terminal.py +80 -45
- {ibm5250-0.1.0.dev8 → ibm5250-0.1.0.dev10}/uv.lock +1 -1
- {ibm5250-0.1.0.dev8 → ibm5250-0.1.0.dev10}/.github/copilot-instructions.md +0 -0
- {ibm5250-0.1.0.dev8 → ibm5250-0.1.0.dev10}/.github/workflows/README.md +0 -0
- {ibm5250-0.1.0.dev8 → ibm5250-0.1.0.dev10}/.github/workflows/publish.yml +0 -0
- {ibm5250-0.1.0.dev8 → ibm5250-0.1.0.dev10}/.gitignore +0 -0
- {ibm5250-0.1.0.dev8 → ibm5250-0.1.0.dev10}/LICENSE +0 -0
- {ibm5250-0.1.0.dev8 → ibm5250-0.1.0.dev10}/README.md +0 -0
- {ibm5250-0.1.0.dev8 → ibm5250-0.1.0.dev10}/scripts/compute_version.py +0 -0
- {ibm5250-0.1.0.dev8 → ibm5250-0.1.0.dev10}/src/ibm5250/constants.py +0 -0
- {ibm5250-0.1.0.dev8 → ibm5250-0.1.0.dev10}/src/ibm5250/exceptions.py +0 -0
|
@@ -35,6 +35,9 @@ from .datastream import (
|
|
|
35
35
|
DSEventType,
|
|
36
36
|
FieldResponse,
|
|
37
37
|
build_aid_response,
|
|
38
|
+
build_save_screen_response,
|
|
39
|
+
decode_save_screen_fields,
|
|
40
|
+
decode_save_screen_image,
|
|
38
41
|
ebcdic_decode,
|
|
39
42
|
ebcdic_decode_stripped,
|
|
40
43
|
ebcdic_encode,
|
|
@@ -81,6 +84,9 @@ __all__ = [
|
|
|
81
84
|
"DSEventType",
|
|
82
85
|
"FieldResponse",
|
|
83
86
|
"build_aid_response",
|
|
87
|
+
"build_save_screen_response",
|
|
88
|
+
"decode_save_screen_image",
|
|
89
|
+
"decode_save_screen_fields",
|
|
84
90
|
# EBCDIC helpers
|
|
85
91
|
"ebcdic_encode",
|
|
86
92
|
"ebcdic_decode",
|
|
@@ -26,7 +26,6 @@ from dataclasses import dataclass
|
|
|
26
26
|
|
|
27
27
|
from .constants import (
|
|
28
28
|
AID,
|
|
29
|
-
Command,
|
|
30
29
|
TelnetCmd,
|
|
31
30
|
TelnetOpt,
|
|
32
31
|
TN5250EDataType,
|
|
@@ -62,6 +61,15 @@ _TN5250E_ATTN_FLAG = 0x40
|
|
|
62
61
|
# host will run its Attention program (e.g. the System Request / Assist menu).
|
|
63
62
|
_TN5250E_OP_CANCEL_INVITE = 0x0A
|
|
64
63
|
|
|
64
|
+
# Byte 9 operation code for the Save Screen response record (RFC 2877 §4.3).
|
|
65
|
+
_TN5250E_OP_SAVE_SCREEN_RESPONSE = 0x04
|
|
66
|
+
|
|
67
|
+
# Byte 9 operation codes for immediate-read replies (RFC 2877 §4.3). The host
|
|
68
|
+
# polls the workstation with ESC + Read Immediate (0x72) / Read Screen Immediate
|
|
69
|
+
# (0x62); the workstation answers with these operation codes.
|
|
70
|
+
_TN5250E_OP_READ_IMMEDIATE = 0x06
|
|
71
|
+
_TN5250E_OP_READ_SCREEN = 0x08
|
|
72
|
+
|
|
65
73
|
_MAX_NEG_PASSES = 40 # Maximum Telnet option exchange iterations during negotiation
|
|
66
74
|
|
|
67
75
|
|
|
@@ -71,19 +79,14 @@ _MAX_NEG_PASSES = 40 # Maximum Telnet option exchange iterations during negotia
|
|
|
71
79
|
|
|
72
80
|
_TN5250E_HEADER_LEN = 10
|
|
73
81
|
|
|
74
|
-
# The
|
|
75
|
-
#
|
|
76
|
-
# begins at ``6 + header[6]`` (normally 6 + 4 = 10).
|
|
82
|
+
# The 5250 payload starts after the fixed ten-byte TN5250E header. Byte 9 is
|
|
83
|
+
# the per-direction sequence number, not a variable-header length.
|
|
77
84
|
_TN5250E_FIXED_HEADER_LEN = 6
|
|
78
85
|
|
|
79
86
|
|
|
80
87
|
def _payload_offset(frame: bytes) -> int:
|
|
81
88
|
"""Return the offset of the 5250 payload within a TN5250E *frame*."""
|
|
82
|
-
|
|
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
|
|
89
|
+
return _TN5250E_HEADER_LEN
|
|
87
90
|
|
|
88
91
|
|
|
89
92
|
@dataclass
|
|
@@ -113,7 +116,7 @@ class TN5250EHeader:
|
|
|
113
116
|
self.data_type, # 6: data type
|
|
114
117
|
0x00, # 7: request/response
|
|
115
118
|
self.err_flag, # 8: error recovery
|
|
116
|
-
var_len, # 9:
|
|
119
|
+
var_len, # 9: operation code (var-header length; 3 = Put/Get)
|
|
117
120
|
]
|
|
118
121
|
)
|
|
119
122
|
+ var_hdr
|
|
@@ -337,23 +340,60 @@ class TN5250Connection:
|
|
|
337
340
|
frame = _build_tn5250e_frame(req_resp=0x00, opcode=_TN5250E_OP_CANCEL_INVITE)
|
|
338
341
|
self._send_frame(frame, description="Sending Cancel-Invite response")
|
|
339
342
|
|
|
340
|
-
def send_save_screen_response(self,
|
|
343
|
+
def send_save_screen_response(self, screen_data: bytes) -> None:
|
|
341
344
|
"""Send the TN5250E-level Save Screen acknowledgment.
|
|
342
345
|
|
|
343
346
|
When the server issues an ESC+SAVE_SCREEN (0x04 0x02) command the
|
|
344
|
-
terminal
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
347
|
+
terminal replies with a complete, serialized presentation space. The
|
|
348
|
+
host stores this opaque regeneration buffer and returns it in a later
|
|
349
|
+
Restore Screen command.
|
|
350
|
+
|
|
351
|
+
Parameters
|
|
352
|
+
----------
|
|
353
|
+
screen_data:
|
|
354
|
+
A complete length-prefixed Restore Screen response produced by the
|
|
355
|
+
datastream encoder.
|
|
349
356
|
"""
|
|
350
357
|
if not self._tn5250e_active:
|
|
351
358
|
return # save/restore handshake only occurs in TN5250E mode
|
|
352
359
|
|
|
353
|
-
|
|
354
|
-
|
|
360
|
+
frame = _build_tn5250e_frame(
|
|
361
|
+
req_resp=0x00,
|
|
362
|
+
opcode=_TN5250E_OP_SAVE_SCREEN_RESPONSE,
|
|
363
|
+
var_hdr=screen_data,
|
|
364
|
+
)
|
|
355
365
|
self._send_frame(frame, description="Sending Save Screen response")
|
|
356
366
|
|
|
367
|
+
def send_read_immediate_response(
|
|
368
|
+
self, cursor_pos: int, field_data: bytes, cols: int = 80
|
|
369
|
+
) -> None:
|
|
370
|
+
"""Answer a host Read Immediate (0x72) poll with the current input.
|
|
371
|
+
|
|
372
|
+
The reply carries operation code 0x06, a cursor/AID variable header
|
|
373
|
+
(AID 0x00 — no key was pressed), then the concatenated input-field data.
|
|
374
|
+
"""
|
|
375
|
+
if not self._tn5250e_active:
|
|
376
|
+
return
|
|
377
|
+
cursor_row, cursor_col = encode_addr(cursor_pos, cols)
|
|
378
|
+
var_hdr = bytes([cursor_row, cursor_col, 0x00]) + field_data
|
|
379
|
+
frame = _build_tn5250e_frame(
|
|
380
|
+
req_resp=0x00, opcode=_TN5250E_OP_READ_IMMEDIATE, var_hdr=var_hdr
|
|
381
|
+
)
|
|
382
|
+
self._send_frame(frame, description="Sending Read Immediate response")
|
|
383
|
+
|
|
384
|
+
def send_read_screen_response(self, screen_data: bytes) -> None:
|
|
385
|
+
"""Answer a host Read Screen Immediate (0x62) poll with the buffer.
|
|
386
|
+
|
|
387
|
+
The reply carries operation code 0x08 and the raw presentation-space
|
|
388
|
+
character buffer as its payload (no cursor/AID header).
|
|
389
|
+
"""
|
|
390
|
+
if not self._tn5250e_active:
|
|
391
|
+
return
|
|
392
|
+
frame = _build_tn5250e_frame(
|
|
393
|
+
req_resp=0x00, opcode=_TN5250E_OP_READ_SCREEN, var_hdr=screen_data
|
|
394
|
+
)
|
|
395
|
+
self._send_frame(frame, description="Sending Read Screen response")
|
|
396
|
+
|
|
357
397
|
def recv_record(self) -> bytes:
|
|
358
398
|
"""Block until a complete record arrives; return the raw 5250 payload."""
|
|
359
399
|
while True:
|
|
@@ -772,7 +812,7 @@ def _iac_escape(data: bytes) -> bytes:
|
|
|
772
812
|
|
|
773
813
|
|
|
774
814
|
def _build_tn5250e_frame(*, req_resp: int, opcode: int, var_hdr: bytes = b"") -> bytes:
|
|
775
|
-
"""Build an IAC-EOR-terminated TN5250E frame with
|
|
815
|
+
"""Build an IAC-EOR-terminated TN5250E frame with an optional 5250 payload.
|
|
776
816
|
|
|
777
817
|
Produces the fixed 10-byte header (RFC 2877 §3.3) followed by an optional
|
|
778
818
|
variable header, IAC-escapes it, and appends the IAC-EOR record terminator.
|
|
@@ -784,7 +824,7 @@ def _build_tn5250e_frame(*, req_resp: int, opcode: int, var_hdr: bytes = b"") ->
|
|
|
784
824
|
req_resp:
|
|
785
825
|
Byte 7 (request/response flags) — e.g. the Attn flag.
|
|
786
826
|
opcode:
|
|
787
|
-
Byte 9
|
|
827
|
+
Byte 9 operation code (RFC 2877 §4.3) — how the host identifies the record.
|
|
788
828
|
var_hdr:
|
|
789
829
|
Optional variable-header bytes appended after the fixed header.
|
|
790
830
|
"""
|
|
@@ -800,7 +840,7 @@ def _build_tn5250e_frame(*, req_resp: int, opcode: int, var_hdr: bytes = b"") ->
|
|
|
800
840
|
TN5250EDataType.INPUT, # 6: data type (workstation → host)
|
|
801
841
|
req_resp, # 7: request/response flags
|
|
802
842
|
0x00, # 8: error recovery
|
|
803
|
-
opcode, # 9:
|
|
843
|
+
opcode, # 9: operation code
|
|
804
844
|
]
|
|
805
845
|
)
|
|
806
846
|
return _iac_escape(header + var_hdr) + bytes(
|
|
@@ -38,11 +38,13 @@ The **TN5250Connection** manages the TCP socket to the IBM i (AS/400) host.
|
|
|
38
38
|
### Save Screen Response (`send_save_screen_response`)
|
|
39
39
|
|
|
40
40
|
When the server issues a SAVE_SCREEN command, the terminal must reply with a
|
|
41
|
-
TN5250E-framed
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
41
|
+
TN5250E-framed record carrying `ESC + RESTORE_SCREEN + a two-byte length + the
|
|
42
|
+
current presentation space`. The image is freshly serialized from the current
|
|
43
|
+
screen model: geometry, cursor/home positions, the complete character/attribute
|
|
44
|
+
plane, and the field table. Host-to-terminal WTD, Clear Unit, and Read commands
|
|
45
|
+
are never replayed. The host stores this opaque regeneration buffer and returns
|
|
46
|
+
it in a later Restore Screen command. See
|
|
47
|
+
[Save/Restore Screen Flow](#saverestore-screen-flow).
|
|
46
48
|
|
|
47
49
|
---
|
|
48
50
|
|
|
@@ -130,12 +132,19 @@ terminator for the variable-length part of an SF order.
|
|
|
130
132
|
after SF is then the attribute.
|
|
131
133
|
- The **FFW** (Field Format Word) defines field behaviour — bypass/input,
|
|
132
134
|
keyboard shift, mandatory entry, and so on. Byte 0 always has bit `0x40` set,
|
|
133
|
-
which is how it is told apart from an attribute byte.
|
|
135
|
+
which is how it is told apart from an attribute byte. Byte 1 bit `0x80` is
|
|
136
|
+
**Auto-Enter**: the host expects an Enter AID to be sent automatically the
|
|
137
|
+
moment the field is completely filled (`Field.auto_enter`). On the wire this
|
|
138
|
+
looks like e.g. `1D 40 A0 26 00 01` — FFW `40 A0`, attribute `26`, length `1`
|
|
139
|
+
— which is a plain auto-enter field, _not_ an FCW pair.
|
|
134
140
|
- Zero or more **FCW** (Field Control Word) pairs may follow; the chain ends at
|
|
135
141
|
the first attribute byte, _not_ at a continuation bit.
|
|
136
142
|
- The attribute byte is written at the field's start position, and the field's
|
|
137
143
|
length is the **two bytes after it** — it is not derived from an FCW and it is
|
|
138
144
|
not inferred from where the next field begins.
|
|
145
|
+
- A **Start of Header (SOH)** begins a new format table. Applying it discards
|
|
146
|
+
existing field definitions without clearing display characters; subsequent
|
|
147
|
+
SF orders rebuild the table for the current write.
|
|
139
148
|
|
|
140
149
|
### Write Structured Field (WSF / Query)
|
|
141
150
|
|
|
@@ -170,6 +179,8 @@ The **Screen** holds the actual state that represents what you'd see on a physic
|
|
|
170
179
|
- **Field list** — all SF/MF-defined fields with their position, length, format word, and modified-data-tag.
|
|
171
180
|
- **Cursor position** — where the blinking cursor sits.
|
|
172
181
|
- **Keyboard locked flag** — when True, the terminal won't send AID keys.
|
|
182
|
+
- **Last AID** — the most recent AID successfully sent to the host; included
|
|
183
|
+
in Save Screen device state.
|
|
173
184
|
|
|
174
185
|
### Applying Events
|
|
175
186
|
|
|
@@ -210,8 +221,8 @@ This is the central method — it:
|
|
|
210
221
|
2. Feeds it through `parser.parse()` to get a list of `DSEvent` objects.
|
|
211
222
|
3. Iterates through events looking for special commands:
|
|
212
223
|
- **QUERY** → sends back a Query Reply with terminal capabilities
|
|
213
|
-
- **SAVE_SCREEN** → deep-copies the current `Screen
|
|
214
|
-
- **RESTORE_SCREEN** →
|
|
224
|
+
- **SAVE_SCREEN** → deep-copies the current `Screen` onto a stack, serializes one complete regeneration buffer from that model, and returns it in the save-screen response
|
|
225
|
+
- **RESTORE_SCREEN** → pops the most recently saved `Screen`; the host-returned regeneration buffer is opaque because the local copy is authoritative, then any remaining events from the same record are applied on top
|
|
215
226
|
- **RESIZE_SCREEN** → re-creates the parser and screen at the new dimensions
|
|
216
227
|
4. If no RESTORE occurred, applies all events to the screen normally.
|
|
217
228
|
5. **Post-RESTORE keyboard drain**: After restoring a screen, the keyboard is locked (because it was locked when saved). The server sends a follow-up record to unlock it. The terminal tries one more `_recv_and_apply()` to consume that unlock record. If nothing comes (RecvTimeout), it moves on.
|
|
@@ -224,6 +235,16 @@ This is the central method — it:
|
|
|
224
235
|
4. Sets `keyboard_locked = True` (the server will unlock it when it replies).
|
|
225
236
|
5. Calls `_recv_and_apply()` to wait for and process the server's response.
|
|
226
237
|
|
|
238
|
+
### Auto-Enter fields
|
|
239
|
+
|
|
240
|
+
When `type_into()` fills a field whose FFW has the Auto-Enter bit set, it
|
|
241
|
+
submits `AID.ENTER` itself (via the normal `send_key` path) rather than waiting
|
|
242
|
+
for the caller — mirroring a physical 5250. The cursor is placed on the
|
|
243
|
+
auto-enter field first, because `set_field_text` otherwise advances it to the
|
|
244
|
+
next field once the current one is full. Only the modified-data tags are cleared
|
|
245
|
+
locally; the resulting screen (often a Save/Restore popup handshake) is driven
|
|
246
|
+
entirely by the host and handled by `_recv_and_apply()`.
|
|
247
|
+
|
|
227
248
|
### Waiting: `wait_for_text(text)`
|
|
228
249
|
|
|
229
250
|
1. Checks if the text already appears on screen (from events already applied).
|
|
@@ -34,6 +34,10 @@ import struct
|
|
|
34
34
|
from collections.abc import Iterator
|
|
35
35
|
from dataclasses import dataclass, field
|
|
36
36
|
from enum import Enum, auto
|
|
37
|
+
from typing import TYPE_CHECKING
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from .screen import Screen
|
|
37
41
|
|
|
38
42
|
from .constants import (
|
|
39
43
|
AID,
|
|
@@ -41,7 +45,6 @@ from .constants import (
|
|
|
41
45
|
EBCDIC_SPACE,
|
|
42
46
|
FFW,
|
|
43
47
|
KNOWN_ORDERS,
|
|
44
|
-
SAVE_SCREEN_ID_LEN,
|
|
45
48
|
SCREEN_COLS,
|
|
46
49
|
SCREEN_COLS_132,
|
|
47
50
|
SCREEN_ROWS,
|
|
@@ -82,6 +85,7 @@ class DSEventType(Enum):
|
|
|
82
85
|
SET_ATTRIBUTE = auto() # Set a display/character attribute (SA)
|
|
83
86
|
COMMAND = auto() # Raw command code (WTD, read, etc.)
|
|
84
87
|
QUERY = auto() # Server requests terminal capability query reply
|
|
88
|
+
START_OF_HEADER = auto() # SOH order carrying the current format header
|
|
85
89
|
|
|
86
90
|
|
|
87
91
|
@dataclass
|
|
@@ -219,27 +223,33 @@ class DataStreamParser:
|
|
|
219
223
|
yield from self._parse_wsf(data_view, byte_offset, total_bytes)
|
|
220
224
|
return
|
|
221
225
|
elif sub_command in (Command.SAVE_SCREEN, Command.RESTORE_SCREEN):
|
|
222
|
-
# The server is issuing a save/restore command with an ESC
|
|
223
|
-
# prefix. A Restore Screen carries the two-byte screen
|
|
224
|
-
# identifier that the workstation returned when the host
|
|
225
|
-
# asked it to save that screen.
|
|
226
226
|
byte_offset += 1
|
|
227
|
-
|
|
228
|
-
if
|
|
229
|
-
|
|
230
|
-
and total_bytes - byte_offset == SAVE_SCREEN_ID_LEN
|
|
231
|
-
):
|
|
232
|
-
save_id = bytes(data_view[byte_offset:total_bytes])
|
|
227
|
+
restore_data = b""
|
|
228
|
+
if sub_command == Command.RESTORE_SCREEN:
|
|
229
|
+
restore_data = bytes(data_view[byte_offset:total_bytes])
|
|
233
230
|
byte_offset = total_bytes
|
|
234
231
|
log.debug(
|
|
235
|
-
"ESC + command 0x%02X (
|
|
232
|
+
"ESC + command 0x%02X (%d restore bytes) — emitting COMMAND event",
|
|
236
233
|
sub_command,
|
|
237
|
-
|
|
234
|
+
len(restore_data),
|
|
238
235
|
)
|
|
239
236
|
yield DSEvent(
|
|
240
|
-
type=DSEventType.COMMAND,
|
|
237
|
+
type=DSEventType.COMMAND,
|
|
238
|
+
command=sub_command,
|
|
239
|
+
data=restore_data,
|
|
241
240
|
)
|
|
242
241
|
return
|
|
242
|
+
elif sub_command in (
|
|
243
|
+
Command.READ_INPUT_FIELDS,
|
|
244
|
+
Command.READ_MDT_FIELDS,
|
|
245
|
+
Command.READ_IMMEDIATE,
|
|
246
|
+
Command.READ_SCREEN_IMMEDIATE,
|
|
247
|
+
):
|
|
248
|
+
# ESC + Read command (e.g. 04 72): the host polls the
|
|
249
|
+
# workstation for input; no data stream follows.
|
|
250
|
+
byte_offset += 1
|
|
251
|
+
yield DSEvent(type=DSEventType.COMMAND, command=sub_command)
|
|
252
|
+
return
|
|
243
253
|
else:
|
|
244
254
|
if sub_command == Command.WRITE_TO_DISPLAY:
|
|
245
255
|
byte_offset += 1 # consume the 0x11 WTD byte
|
|
@@ -301,10 +311,13 @@ class DataStreamParser:
|
|
|
301
311
|
current_buffer_position + len(data_chunk)
|
|
302
312
|
) % self._size
|
|
303
313
|
else:
|
|
314
|
+
soh_start = byte_offset # at the length byte
|
|
304
315
|
byte_offset += 1
|
|
305
|
-
byte_offset += max(
|
|
306
|
-
|
|
307
|
-
|
|
316
|
+
byte_offset += max(0, start_of_header_length)
|
|
317
|
+
yield DSEvent(
|
|
318
|
+
type=DSEventType.START_OF_HEADER,
|
|
319
|
+
data=bytes(data_view[soh_start:byte_offset]),
|
|
320
|
+
)
|
|
308
321
|
|
|
309
322
|
case Order.TRANSPARENT_DATA:
|
|
310
323
|
byte_offset += 1
|
|
@@ -618,6 +631,228 @@ class DataStreamParser:
|
|
|
618
631
|
# ---------------------------------------------------------------------------
|
|
619
632
|
|
|
620
633
|
|
|
634
|
+
# Fixed segments of the Save/Restore Screen (04 12) structured field, taken
|
|
635
|
+
# from a known-good "nikkie 6.1" reply. Unlike a Write To Display (04 11)
|
|
636
|
+
# packet, this structured field is a self-describing snapshot: order bytes in
|
|
637
|
+
# the buffer are shift-escaped with 0x10, and the buffer is preceded by a
|
|
638
|
+
# device identity/capability header and a device/keyboard state block.
|
|
639
|
+
_SAVE_SCREEN_SHIFT = 0x10
|
|
640
|
+
_SAVE_SCREEN_DEVICE_ID = b"\x10\x01\x0bnikkie 6.1\x00"
|
|
641
|
+
_SAVE_SCREEN_CAPS = bytes.fromhex("08 20 08 1f 1a 07 7f") # 24x80 device capabilities
|
|
642
|
+
_SAVE_SCREEN_MARKER = b"\x18\x50" # begin 80-column display snapshot
|
|
643
|
+
_SAVE_SCREEN_BUFFER_INTRO = 0x04 # 10 04 <len> <dims> <character plane>
|
|
644
|
+
_SAVE_SCREEN_DEVICE_STATE = b"\x01\x01"
|
|
645
|
+
_SAVE_SCREEN_PLANE_STATE = b"\x01\x00\x00"
|
|
646
|
+
_SAVE_SCREEN_CURSOR_TAG = 0x02
|
|
647
|
+
# Active-field pointer: 00 00 <field id> 00. The field id is screen-dependent
|
|
648
|
+
# (the format-table index the cursor sits in); pinned until derivation is
|
|
649
|
+
# verified against more capture frames. Never read back on our restore path.
|
|
650
|
+
_SAVE_SCREEN_ACTIVE_FIELD_POINTER = b"\x00\x00\x01\x00"
|
|
651
|
+
# Constant keyboard/shift/insert mode flags that follow the field pointer.
|
|
652
|
+
_SAVE_SCREEN_INPUT_MODE_FLAGS = b"\x00\x01\x01\x00\x01\x00"
|
|
653
|
+
# The AID byte (screen.last_aid) is emitted here, between the mode flags and
|
|
654
|
+
# the trailer — it is dynamic, not part of either constant.
|
|
655
|
+
_SAVE_SCREEN_INPUT_STATE_TRAILER = bytes.fromhex("00 0b 00 00 00 03 00 00")
|
|
656
|
+
# Two full presentation-space window definitions are derived from geometry.
|
|
657
|
+
# Neutral format header used when no host SOH has been received.
|
|
658
|
+
_SAVE_SCREEN_DEFAULT_SOH = b"\x07" + bytes(7)
|
|
659
|
+
# Per-field descriptor flag: 0xC5 for input-attribute (0x24) fields, 0xC7 otherwise.
|
|
660
|
+
_SAVE_SCREEN_INPUT_ATTR = 0x24
|
|
661
|
+
_SAVE_SCREEN_FIELD_FLAG_INPUT = 0xC5
|
|
662
|
+
_SAVE_SCREEN_FIELD_FLAG_OTHER = 0xC7
|
|
663
|
+
_SAVE_SCREEN_FIELD_ENTRY_LEN = 9 # flag(3) + data_pos(2) + len(1) + ffw(2) + attr(1)
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def build_save_screen_response(screen: Screen) -> bytes:
|
|
667
|
+
"""Serialize the current screen as a Save/Restore Screen (04 12) reply.
|
|
668
|
+
|
|
669
|
+
The reply mirrors a "nikkie 6.1" save: device identity, two window
|
|
670
|
+
descriptors, a device-state block, the run-length-compressed image
|
|
671
|
+
(``10 04``), and the trailing window (``10 05`` / ``10 06``) and
|
|
672
|
+
field-format (``10 08``) sections. The two-byte length after
|
|
673
|
+
``ESC RESTORE_SCREEN`` includes itself.
|
|
674
|
+
"""
|
|
675
|
+
size = screen.rows * screen.cols
|
|
676
|
+
image = _compress_save_screen_plane(bytes(screen._chars[:size]))
|
|
677
|
+
|
|
678
|
+
body = bytearray()
|
|
679
|
+
body += _SAVE_SCREEN_DEVICE_ID
|
|
680
|
+
body += b"\x10\x02" + _SAVE_SCREEN_CAPS
|
|
681
|
+
body += _SAVE_SCREEN_MARKER
|
|
682
|
+
body += _SAVE_SCREEN_CAPS # second window descriptor (bare, no 10 02)
|
|
683
|
+
body += _SAVE_SCREEN_MARKER
|
|
684
|
+
body += _save_screen_state_block(screen)
|
|
685
|
+
body += bytes([_SAVE_SCREEN_SHIFT, _SAVE_SCREEN_BUFFER_INTRO])
|
|
686
|
+
body += struct.pack(">H", 4 + len(image)) # length counts LL LL + SS SS + image
|
|
687
|
+
body += struct.pack(">H", size)
|
|
688
|
+
body += image
|
|
689
|
+
body += _save_screen_windows(size)
|
|
690
|
+
body += _save_screen_format_table(screen)
|
|
691
|
+
|
|
692
|
+
return (
|
|
693
|
+
bytes([Command.ESC, Command.RESTORE_SCREEN])
|
|
694
|
+
+ struct.pack(">H", 2 + len(body))
|
|
695
|
+
+ bytes(body)
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _dims_for_size(size: int) -> tuple[int, int]:
|
|
700
|
+
"""Map a plane size back to (rows, cols) for the two supported geometries."""
|
|
701
|
+
if size == SCREEN_ROWS_27 * SCREEN_COLS_132:
|
|
702
|
+
return SCREEN_ROWS_27, SCREEN_COLS_132
|
|
703
|
+
return SCREEN_ROWS, SCREEN_COLS
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def decode_save_screen_image(buffer: bytes) -> tuple[int, int, bytes] | None:
|
|
707
|
+
"""Extract ``(rows, cols, character_plane)`` from a Save/Restore buffer.
|
|
708
|
+
|
|
709
|
+
The inverse of the ``10 04`` image built by
|
|
710
|
+
:func:`build_save_screen_response`: locate the section, read its declared
|
|
711
|
+
length and dimensions, and run-length-decode the plane. Returns ``None``
|
|
712
|
+
when no image section is present.
|
|
713
|
+
"""
|
|
714
|
+
marker = buffer.find(bytes([_SAVE_SCREEN_SHIFT, _SAVE_SCREEN_BUFFER_INTRO]))
|
|
715
|
+
if marker == -1 or marker + 6 > len(buffer):
|
|
716
|
+
return None
|
|
717
|
+
declared_len = int.from_bytes(buffer[marker + 2 : marker + 4], "big")
|
|
718
|
+
size = int.from_bytes(buffer[marker + 4 : marker + 6], "big")
|
|
719
|
+
image = buffer[marker + 6 : marker + 6 + declared_len - 4]
|
|
720
|
+
plane = bytearray()
|
|
721
|
+
index = 0
|
|
722
|
+
total = len(image)
|
|
723
|
+
while index < total and len(plane) < size:
|
|
724
|
+
if (
|
|
725
|
+
image[index] == _SAVE_SCREEN_SHIFT
|
|
726
|
+
and index + 3 < total
|
|
727
|
+
and image[index + 1] == Order.SET_BUFFER_ADDRESS
|
|
728
|
+
):
|
|
729
|
+
count = image[index + 2]
|
|
730
|
+
plane += bytes([image[index + 3]]) * count
|
|
731
|
+
index += 4
|
|
732
|
+
else:
|
|
733
|
+
plane.append(image[index])
|
|
734
|
+
index += 1
|
|
735
|
+
if len(plane) < size:
|
|
736
|
+
plane += bytes([EBCDIC_SPACE]) * (size - len(plane))
|
|
737
|
+
rows, cols = _dims_for_size(size)
|
|
738
|
+
return rows, cols, bytes(plane[:size])
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _save_screen_state_block(screen: Screen) -> bytes:
|
|
742
|
+
"""Return the device-state block with live cursor and screen geometry."""
|
|
743
|
+
cursor_pos = screen.cursor_pos
|
|
744
|
+
rows = screen.rows
|
|
745
|
+
cols = screen.cols
|
|
746
|
+
size = rows * cols
|
|
747
|
+
last_row_start = size - cols
|
|
748
|
+
last_cell = size - 1
|
|
749
|
+
pos_hi, pos_lo = (cursor_pos >> 8) & 0xFF, cursor_pos & 0xFF
|
|
750
|
+
row, col = encode_addr(cursor_pos, cols)
|
|
751
|
+
|
|
752
|
+
block = bytearray(9) # no format-area pointers
|
|
753
|
+
block += struct.pack(">HBB", cursor_pos, row, col)
|
|
754
|
+
block += _SAVE_SCREEN_DEVICE_STATE
|
|
755
|
+
block += bytes(7)
|
|
756
|
+
block += struct.pack(">BHHB", rows, last_row_start, last_cell, 0)
|
|
757
|
+
block += struct.pack(">BBHH", rows, rows, last_row_start, last_cell)
|
|
758
|
+
block += bytes(5)
|
|
759
|
+
block += struct.pack(
|
|
760
|
+
">BHHHH", rows, last_row_start, last_cell, last_row_start + 1, last_cell - 1
|
|
761
|
+
)
|
|
762
|
+
block += struct.pack(">BHH", rows + 1, size, size + cols - 1)
|
|
763
|
+
block += struct.pack(">BHH", rows + 2, size + cols, size + 2 * cols - 1)
|
|
764
|
+
block += _SAVE_SCREEN_PLANE_STATE
|
|
765
|
+
block += bytes([_SAVE_SCREEN_CURSOR_TAG, pos_hi, pos_lo])
|
|
766
|
+
block += _SAVE_SCREEN_ACTIVE_FIELD_POINTER
|
|
767
|
+
block += _SAVE_SCREEN_INPUT_MODE_FLAGS
|
|
768
|
+
block += bytes([screen.last_aid]) # dynamic: the last AID key sent
|
|
769
|
+
block += _SAVE_SCREEN_INPUT_STATE_TRAILER
|
|
770
|
+
return bytes(block)
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _save_screen_windows(size: int) -> bytes:
|
|
774
|
+
"""Build the 10 05 / 10 06 window definitions sized to the screen."""
|
|
775
|
+
geometry = struct.pack(">H", size)
|
|
776
|
+
section = b"\x00\x09" + geometry + b"\x10\x12" + geometry + b"\xc0"
|
|
777
|
+
return b"\x10\x05" + section + b"\x10\x06" + section
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def _save_screen_format_table(screen: Screen) -> bytes:
|
|
781
|
+
"""Build the 10 08 field-format table from the host SOH and input fields.
|
|
782
|
+
|
|
783
|
+
Each field is a fixed 9-byte descriptor: a 3-byte flag prefix, a 2-byte
|
|
784
|
+
data position, a length byte, the 2-byte FFW, and the attribute byte.
|
|
785
|
+
"""
|
|
786
|
+
header = screen.last_start_of_header or _SAVE_SCREEN_DEFAULT_SOH
|
|
787
|
+
entries = bytearray()
|
|
788
|
+
for input_field in screen.input_fields:
|
|
789
|
+
flag = (
|
|
790
|
+
_SAVE_SCREEN_FIELD_FLAG_INPUT
|
|
791
|
+
if input_field.attribute == _SAVE_SCREEN_INPUT_ATTR
|
|
792
|
+
else _SAVE_SCREEN_FIELD_FLAG_OTHER
|
|
793
|
+
)
|
|
794
|
+
entries += bytes([flag, 0x00, 0x00])
|
|
795
|
+
entries += struct.pack(">H", input_field.data_pos)
|
|
796
|
+
entries += bytes([input_field.length & 0xFF])
|
|
797
|
+
entries += input_field.field_format_word[:2].ljust(2, b"\x00")
|
|
798
|
+
entries += bytes([input_field.attribute])
|
|
799
|
+
payload = header + bytes([len(screen.input_fields) & 0xFF]) + bytes(entries)
|
|
800
|
+
return b"\x10\x08" + struct.pack(">H", 2 + len(payload)) + bytes(payload)
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def decode_save_screen_fields(buffer: bytes) -> list[tuple[int, int, bytes, int]]:
|
|
804
|
+
"""Extract field specs from a Save/Restore 10 08 table.
|
|
805
|
+
|
|
806
|
+
The inverse of :func:`_save_screen_format_table`: each field is a fixed
|
|
807
|
+
9-byte descriptor. Returns ``(start_pos, length, ffw, attribute)`` tuples,
|
|
808
|
+
or an empty list when no format table is present.
|
|
809
|
+
"""
|
|
810
|
+
marker = buffer.find(b"\x10\x08")
|
|
811
|
+
if marker == -1 or marker + 4 > len(buffer):
|
|
812
|
+
return []
|
|
813
|
+
declared_len = int.from_bytes(buffer[marker + 2 : marker + 4], "big")
|
|
814
|
+
payload = buffer[marker + 4 : marker + 4 + declared_len - 2]
|
|
815
|
+
if not payload:
|
|
816
|
+
return []
|
|
817
|
+
soh_len = payload[0]
|
|
818
|
+
index = 1 + soh_len
|
|
819
|
+
if index >= len(payload):
|
|
820
|
+
return []
|
|
821
|
+
count = payload[index]
|
|
822
|
+
index += 1
|
|
823
|
+
specs: list[tuple[int, int, bytes, int]] = []
|
|
824
|
+
for _ in range(count):
|
|
825
|
+
entry = payload[index : index + _SAVE_SCREEN_FIELD_ENTRY_LEN]
|
|
826
|
+
if len(entry) < _SAVE_SCREEN_FIELD_ENTRY_LEN:
|
|
827
|
+
break
|
|
828
|
+
data_pos = int.from_bytes(entry[3:5], "big")
|
|
829
|
+
specs.append((max(0, data_pos - 1), entry[5], bytes(entry[6:8]), entry[8]))
|
|
830
|
+
index += _SAVE_SCREEN_FIELD_ENTRY_LEN
|
|
831
|
+
return specs
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def _compress_save_screen_plane(plane: bytes) -> bytes:
|
|
835
|
+
"""Run-length compress a linear cell plane for a Save Screen image.
|
|
836
|
+
|
|
837
|
+
Runs of four or more identical bytes become ``10 11 <count> <byte>``;
|
|
838
|
+
shorter runs are emitted literally. Counts are capped at 255 per order.
|
|
839
|
+
"""
|
|
840
|
+
out = bytearray()
|
|
841
|
+
index = 0
|
|
842
|
+
length = len(plane)
|
|
843
|
+
while index < length:
|
|
844
|
+
byte = plane[index]
|
|
845
|
+
run = 1
|
|
846
|
+
while index + run < length and plane[index + run] == byte and run < 0xFF:
|
|
847
|
+
run += 1
|
|
848
|
+
if run >= 4:
|
|
849
|
+
out += bytes([_SAVE_SCREEN_SHIFT, Order.SET_BUFFER_ADDRESS, run, byte])
|
|
850
|
+
else:
|
|
851
|
+
out += bytes([byte]) * run
|
|
852
|
+
index += run
|
|
853
|
+
return bytes(out)
|
|
854
|
+
|
|
855
|
+
|
|
621
856
|
@dataclass
|
|
622
857
|
class FieldResponse:
|
|
623
858
|
"""One field's contribution to an AID response."""
|
|
@@ -19,6 +19,7 @@ from collections.abc import Sequence
|
|
|
19
19
|
from dataclasses import dataclass, field
|
|
20
20
|
|
|
21
21
|
from .constants import (
|
|
22
|
+
AID,
|
|
22
23
|
CC2,
|
|
23
24
|
EBCDIC_NULL,
|
|
24
25
|
EBCDIC_SPACE,
|
|
@@ -26,10 +27,18 @@ from .constants import (
|
|
|
26
27
|
SCREEN_COLS,
|
|
27
28
|
SCREEN_ROWS,
|
|
28
29
|
Attr,
|
|
30
|
+
Command,
|
|
29
31
|
pos_to_rowcol,
|
|
30
32
|
rowcol_to_pos,
|
|
31
33
|
)
|
|
32
|
-
from .datastream import
|
|
34
|
+
from .datastream import (
|
|
35
|
+
DSEvent,
|
|
36
|
+
DSEventType,
|
|
37
|
+
decode_save_screen_fields,
|
|
38
|
+
decode_save_screen_image,
|
|
39
|
+
ebcdic_decode,
|
|
40
|
+
ebcdic_encode,
|
|
41
|
+
)
|
|
33
42
|
from .exceptions import FieldNotFound
|
|
34
43
|
|
|
35
44
|
log = logging.getLogger(__name__)
|
|
@@ -108,6 +117,28 @@ class Field:
|
|
|
108
117
|
"""True when the field's display attribute is 'non-display' (password)."""
|
|
109
118
|
return self.attribute & Attr.NON_DISPLAY_MASK == Attr.NON_DISPLAY_MASK
|
|
110
119
|
|
|
120
|
+
@property
|
|
121
|
+
def auto_enter(self) -> bool:
|
|
122
|
+
"""True when the FFW Auto-Enter bit is set.
|
|
123
|
+
|
|
124
|
+
The host submits an Enter AID automatically as soon as such a field is
|
|
125
|
+
completely filled.
|
|
126
|
+
"""
|
|
127
|
+
return len(self.field_format_word) >= 2 and bool(
|
|
128
|
+
self.field_format_word[1] & FFW.AUTO_ENTER
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def writable_length(self) -> int:
|
|
133
|
+
"""Number of positions writable on the field's first row.
|
|
134
|
+
|
|
135
|
+
A field's formal area can span to the next field, but data is only
|
|
136
|
+
written up to the end of the row holding the first data byte (see
|
|
137
|
+
:meth:`Screen.set_field_text`), so auto-enter fills at this boundary.
|
|
138
|
+
"""
|
|
139
|
+
row_remaining = self._screen_cols - (self.data_pos % self._screen_cols)
|
|
140
|
+
return max(0, min(self.length, row_remaining))
|
|
141
|
+
|
|
111
142
|
@property
|
|
112
143
|
def mdt(self) -> bool:
|
|
113
144
|
"""True if the Modified Data Tag is set (field was touched)."""
|
|
@@ -164,22 +195,39 @@ class Screen:
|
|
|
164
195
|
self._keyboard_locked: bool = True
|
|
165
196
|
self._write_lengths: dict[int, int] = {} # pos → length of last write
|
|
166
197
|
self._pending_scroll_clear: bool = False
|
|
198
|
+
self._last_start_of_header: bytes = b"" # SOH header from the last WTD
|
|
199
|
+
self._last_aid: int = AID.NO_AID
|
|
167
200
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
201
|
+
@classmethod
|
|
202
|
+
def from_save_buffer(cls, buffer: bytes) -> Screen | None:
|
|
203
|
+
"""Reconstruct a screen from a host Save/Restore Screen buffer.
|
|
204
|
+
|
|
205
|
+
The 04 12 buffer is self-contained: the character plane comes from its
|
|
206
|
+
10 04 image and every field from its 10 08 format table. Returns None
|
|
207
|
+
when the buffer has no image section.
|
|
208
|
+
"""
|
|
209
|
+
decoded = decode_save_screen_image(buffer)
|
|
210
|
+
if decoded is None:
|
|
211
|
+
return None
|
|
212
|
+
rows, cols, plane = decoded
|
|
213
|
+
screen = cls(rows=rows, cols=cols)
|
|
214
|
+
screen._chars[: len(plane)] = plane
|
|
215
|
+
for start_pos, length, ffw, attribute in decode_save_screen_fields(buffer):
|
|
216
|
+
if not 0 <= start_pos < screen._size:
|
|
217
|
+
continue
|
|
218
|
+
screen._fields.append(
|
|
219
|
+
Field(
|
|
220
|
+
start_pos=start_pos,
|
|
221
|
+
length=length,
|
|
222
|
+
field_format_word=ffw,
|
|
223
|
+
attribute=attribute or Attr.NORMAL,
|
|
224
|
+
_explicit_length=length,
|
|
225
|
+
_screen_cols=cols,
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
screen._attrs[start_pos] = attribute
|
|
229
|
+
screen._fields.sort(key=lambda f: f.start_pos)
|
|
230
|
+
return screen
|
|
183
231
|
|
|
184
232
|
# ------------------------------------------------------------------
|
|
185
233
|
# Event application
|
|
@@ -204,6 +252,9 @@ class Screen:
|
|
|
204
252
|
log.debug("Screen resized to %d rows × %d cols", self.rows, self.cols)
|
|
205
253
|
|
|
206
254
|
elif event_type == DSEventType.COMMAND:
|
|
255
|
+
if event.command == Command.CLEAR_FORMAT_TABLE:
|
|
256
|
+
self._fields.clear()
|
|
257
|
+
self._write_lengths.clear()
|
|
207
258
|
cc2 = event.control_char_2
|
|
208
259
|
if cc2 & CC2.RESET_MDT:
|
|
209
260
|
for screen_field in self._fields:
|
|
@@ -231,6 +282,11 @@ class Screen:
|
|
|
231
282
|
elif event_type == DSEventType.SET_ATTRIBUTE and 0 <= event.position < self._size:
|
|
232
283
|
self._attrs[event.position] = event.attribute_value
|
|
233
284
|
|
|
285
|
+
elif event_type == DSEventType.START_OF_HEADER:
|
|
286
|
+
self._fields.clear()
|
|
287
|
+
self._write_lengths.clear()
|
|
288
|
+
self._last_start_of_header = event.data
|
|
289
|
+
|
|
234
290
|
def apply_all(self, events: Sequence[DSEvent]) -> None:
|
|
235
291
|
"""Apply a sequence of events, then recalculate field lengths."""
|
|
236
292
|
deferred_unlock = False
|
|
@@ -284,6 +340,10 @@ class Screen:
|
|
|
284
340
|
def cursor_pos(self) -> int:
|
|
285
341
|
return self._cursor
|
|
286
342
|
|
|
343
|
+
@property
|
|
344
|
+
def last_aid(self) -> int:
|
|
345
|
+
return self._last_aid
|
|
346
|
+
|
|
287
347
|
@property
|
|
288
348
|
def cursor_row(self) -> int:
|
|
289
349
|
return pos_to_rowcol(self._cursor, self.cols)[0]
|
|
@@ -296,6 +356,11 @@ class Screen:
|
|
|
296
356
|
def keyboard_locked(self) -> bool:
|
|
297
357
|
return self._keyboard_locked
|
|
298
358
|
|
|
359
|
+
@property
|
|
360
|
+
def last_start_of_header(self) -> bytes:
|
|
361
|
+
"""SOH header bytes (length + data) from the most recent WTD."""
|
|
362
|
+
return self._last_start_of_header
|
|
363
|
+
|
|
299
364
|
@property
|
|
300
365
|
def fields(self) -> list[Field]:
|
|
301
366
|
"""All fields (protected and input) on the current screen."""
|
|
@@ -305,7 +370,6 @@ class Screen:
|
|
|
305
370
|
def input_fields(self) -> list[Field]:
|
|
306
371
|
"""Only unprotected (input) fields."""
|
|
307
372
|
return [f for f in self._fields if not f.is_protected]
|
|
308
|
-
|
|
309
373
|
# ---- text extraction ----
|
|
310
374
|
|
|
311
375
|
def get_text(self, row: int, col: int, length: int) -> str:
|
|
@@ -568,11 +632,9 @@ class Screen:
|
|
|
568
632
|
return [f for f in self._fields if f.mdt and not f.is_protected]
|
|
569
633
|
|
|
570
634
|
def clear_modified_fields(self) -> None:
|
|
571
|
-
"""
|
|
635
|
+
"""Clear field modification bookkeeping without changing screen data."""
|
|
572
636
|
for f in self._fields:
|
|
573
|
-
if f.mdt
|
|
574
|
-
pos = f.data_pos
|
|
575
|
-
self._chars[pos : pos + len(f._saved_data)] = f._saved_data
|
|
637
|
+
if f.mdt:
|
|
576
638
|
f._saved_data = b""
|
|
577
639
|
f.mdt = False
|
|
578
640
|
|
|
@@ -40,6 +40,7 @@ from .datastream import (
|
|
|
40
40
|
FieldResponse,
|
|
41
41
|
build_aid_response,
|
|
42
42
|
build_query_reply,
|
|
43
|
+
build_save_screen_response,
|
|
43
44
|
)
|
|
44
45
|
from .exceptions import (
|
|
45
46
|
ConnectionError,
|
|
@@ -78,8 +79,12 @@ class FieldProxy:
|
|
|
78
79
|
|
|
79
80
|
@value.setter
|
|
80
81
|
def value(self, text: str) -> None:
|
|
81
|
-
"""Write *text* into the field (marks MDT).
|
|
82
|
-
|
|
82
|
+
"""Write *text* into the field (marks MDT).
|
|
83
|
+
|
|
84
|
+
Routed through :meth:`Terminal.type_into`, so filling a field whose FFW
|
|
85
|
+
has the Auto-Enter bit set submits the host's Enter AID automatically.
|
|
86
|
+
"""
|
|
87
|
+
self._term.type_into(self._field, text)
|
|
83
88
|
|
|
84
89
|
@property
|
|
85
90
|
def field(self) -> Field:
|
|
@@ -154,8 +159,6 @@ class Terminal:
|
|
|
154
159
|
self._poll_interval = poll_interval
|
|
155
160
|
self._rows = rows
|
|
156
161
|
self._cols = cols
|
|
157
|
-
self._saved_screens: list[tuple[bytes, Screen]] = []
|
|
158
|
-
self._next_save_id: int = 1
|
|
159
162
|
|
|
160
163
|
# ------------------------------------------------------------------
|
|
161
164
|
# Connection management
|
|
@@ -250,13 +253,27 @@ class Terminal:
|
|
|
250
253
|
The text to enter.
|
|
251
254
|
clear_first:
|
|
252
255
|
If *True*, clear any existing content first.
|
|
256
|
+
|
|
257
|
+
Notes
|
|
258
|
+
-----
|
|
259
|
+
If *field* has the FFW Auto-Enter bit set and this call fills it, the
|
|
260
|
+
host's Enter AID is sent automatically (blocking for the response), just
|
|
261
|
+
as a physical 5250 terminal would. Do not follow such a call with an
|
|
262
|
+
explicit ``send_key(AID.ENTER)``.
|
|
253
263
|
"""
|
|
254
264
|
f = field.field if isinstance(field, FieldProxy) else field
|
|
255
265
|
if clear_first:
|
|
256
|
-
|
|
266
|
+
content = text
|
|
257
267
|
else:
|
|
258
|
-
|
|
259
|
-
|
|
268
|
+
content = self._screen.get_field_text(f).rstrip() + text
|
|
269
|
+
self._screen.set_field_text(f, content)
|
|
270
|
+
# An auto-enter field makes the host submit Enter the instant it fills.
|
|
271
|
+
# set_field_text advances the cursor to the next field once a field is
|
|
272
|
+
# full, so restore it to this field before submitting to match the AID
|
|
273
|
+
# a real 5250 emulator sends.
|
|
274
|
+
if f.auto_enter and f.writable_length and len(content) >= f.writable_length:
|
|
275
|
+
self._screen._cursor = f.data_pos
|
|
276
|
+
self.send_key(AID.ENTER)
|
|
260
277
|
|
|
261
278
|
def move_cursor(self, row: int, col: int) -> None:
|
|
262
279
|
"""Move the cursor to 1-based *(row, col)* without sending a key."""
|
|
@@ -536,6 +553,7 @@ class Terminal:
|
|
|
536
553
|
cursor_pos=self._screen.cursor_pos,
|
|
537
554
|
cols=self._screen.cols,
|
|
538
555
|
)
|
|
556
|
+
self._screen._last_aid = aid
|
|
539
557
|
self._screen.clear_modified_fields()
|
|
540
558
|
self._screen._keyboard_locked = True
|
|
541
559
|
if aid in (AID.PAGE_DOWN, AID.PAGE_UP):
|
|
@@ -568,6 +586,16 @@ class Terminal:
|
|
|
568
586
|
):
|
|
569
587
|
if self._restore_screen(event.data):
|
|
570
588
|
restore_index = i
|
|
589
|
+
elif (
|
|
590
|
+
event.type == DSEventType.COMMAND
|
|
591
|
+
and event.command == Command.READ_IMMEDIATE
|
|
592
|
+
):
|
|
593
|
+
self._respond_to_read_immediate()
|
|
594
|
+
elif (
|
|
595
|
+
event.type == DSEventType.COMMAND
|
|
596
|
+
and event.command == Command.READ_SCREEN_IMMEDIATE
|
|
597
|
+
):
|
|
598
|
+
self._respond_to_read_screen()
|
|
571
599
|
elif event.type == DSEventType.RESIZE_SCREEN:
|
|
572
600
|
self._resize(event.rows, event.cols)
|
|
573
601
|
# Only apply events after a RESTORE — pre-RESTORE events targeted
|
|
@@ -585,6 +613,7 @@ class Terminal:
|
|
|
585
613
|
else:
|
|
586
614
|
self._screen.apply_all(parsed_events)
|
|
587
615
|
|
|
616
|
+
|
|
588
617
|
def _resize(self, rows: int, cols: int) -> None:
|
|
589
618
|
"""Track a screen-size change and re-create the parser to match."""
|
|
590
619
|
self._rows = rows
|
|
@@ -592,50 +621,56 @@ class Terminal:
|
|
|
592
621
|
self._parser = DataStreamParser(rows, cols)
|
|
593
622
|
log.debug("Parser resized to %d rows × %d cols", rows, cols)
|
|
594
623
|
|
|
595
|
-
def
|
|
596
|
-
"""
|
|
624
|
+
def _respond_to_read_immediate(self) -> None:
|
|
625
|
+
"""Answer a host Read Immediate (0x72) with the current input fields.
|
|
597
626
|
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
627
|
+
Immediate reads solicit the device's input without an AID key, so the
|
|
628
|
+
reply carries AID 0x00 and each input field's data concatenated in
|
|
629
|
+
screen order (no SBA), matching a real 5250 workstation.
|
|
601
630
|
"""
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
631
|
+
cols = self._screen.cols
|
|
632
|
+
field_data = bytearray()
|
|
633
|
+
for input_field in self._screen.input_fields:
|
|
634
|
+
row_remaining = cols - (input_field.data_pos % cols)
|
|
635
|
+
length = min(input_field.length, row_remaining)
|
|
636
|
+
field_data += bytes(
|
|
637
|
+
self._screen._chars[
|
|
638
|
+
input_field.data_pos : input_field.data_pos + length
|
|
639
|
+
]
|
|
640
|
+
)
|
|
641
|
+
self._conn.send_read_immediate_response(
|
|
642
|
+
self._screen.cursor_pos, bytes(field_data), cols=cols
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
def _respond_to_read_screen(self) -> None:
|
|
646
|
+
"""Answer a host Read Screen Immediate (0x62) with the presentation space."""
|
|
647
|
+
size = self._screen.rows * self._screen.cols
|
|
648
|
+
self._conn.send_read_screen_response(bytes(self._screen._chars[:size]))
|
|
649
|
+
|
|
650
|
+
def _save_screen(self) -> None:
|
|
651
|
+
"""Acknowledge the host's Save Screen with a serialized presentation space.
|
|
652
|
+
|
|
653
|
+
The response is a self-contained snapshot; the host stores it and echoes
|
|
654
|
+
it back verbatim on Restore, so no local copy is retained.
|
|
614
655
|
"""
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
656
|
+
response = build_save_screen_response(self._screen)
|
|
657
|
+
log.debug("Saved screen (%d response bytes)", len(response))
|
|
658
|
+
self._conn.send_save_screen_response(response)
|
|
618
659
|
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
i
|
|
622
|
-
for i in range(len(self._saved_screens) - 1, -1, -1)
|
|
623
|
-
if self._saved_screens[i][0] == save_id
|
|
624
|
-
),
|
|
625
|
-
None,
|
|
626
|
-
)
|
|
627
|
-
if index is None:
|
|
628
|
-
# Unknown identifier: fall back to the most recently saved screen.
|
|
629
|
-
log.debug(
|
|
630
|
-
"Restore Screen id %s not found; using most recent save",
|
|
631
|
-
save_id.hex() or "-",
|
|
632
|
-
)
|
|
633
|
-
index = len(self._saved_screens) - 1
|
|
660
|
+
def _restore_screen(self, restore_data: bytes) -> bool:
|
|
661
|
+
"""Restore the screen entirely from the host-returned buffer.
|
|
634
662
|
|
|
635
|
-
self
|
|
636
|
-
|
|
663
|
+
The 04 12 buffer is a self-contained snapshot: its 10 04 image is the
|
|
664
|
+
character plane and its 10 08 table defines every field. No local
|
|
665
|
+
presentation state is consulted.
|
|
666
|
+
"""
|
|
667
|
+
restored = Screen.from_save_buffer(restore_data)
|
|
668
|
+
if restored is None:
|
|
669
|
+
log.debug("Restore Screen buffer had no image — ignoring")
|
|
670
|
+
return False
|
|
671
|
+
self._screen = restored
|
|
637
672
|
self._screen.clear_modified_fields()
|
|
638
|
-
# The
|
|
673
|
+
# The restored screen carries its own dimensions; the parser must follow it.
|
|
639
674
|
self._resize(self._screen.rows, self._screen.cols)
|
|
640
675
|
return True
|
|
641
676
|
|
|
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
|