ibm5250 0.1.0.dev1__tar.gz → 0.1.0.dev3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: ibm5250
3
- Version: 0.1.0.dev1
3
+ Version: 0.1.0.dev3
4
4
  Summary: IBM 5250 terminal automation library
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.11
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ibm5250"
3
- version = "0.1.0.dev1"
3
+ version = "0.1.0.dev3"
4
4
  description = "IBM 5250 terminal automation library"
5
5
  requires-python = ">=3.11"
6
6
 
@@ -52,6 +52,19 @@ _VAR_HDR_OP_HI = 0x08 # TN5250E variable-header opcode byte 0
52
52
  _VAR_HDR_OP_LO = 0x2C # TN5250E variable-header opcode byte 1
53
53
  _SAVE_SCREEN_RESP_CC1 = 0x01 # CC1 value in the Save Screen response variable header
54
54
 
55
+ # TN5250E header byte 7 (request/response flags). Real emulators set bit 0x40
56
+ # here to signal the Attention (Attn) key -- a header-only INPUT record with no
57
+ # AID and no variable header. Observed on the wire when the PC "Esc" key (mapped
58
+ # to Attn) is pressed: 00 0a 12 a0 00 00 04 40 00 00.
59
+ _TN5250E_ATTN_FLAG = 0x40
60
+
61
+ # TN5250E header byte 9 operation code (RFC 2877 §4.3). After the workstation
62
+ # sends Attn, the host cancels its outstanding read invite by sending a
63
+ # header-only Cancel-Invite record (opcode 0x0A, no payload). The workstation
64
+ # must acknowledge it by echoing the same Cancel-Invite record back before the
65
+ # host will run its Attention program (e.g. the System Request / Assist menu).
66
+ _TN5250E_OP_CANCEL_INVITE = 0x0A
67
+
55
68
  _MAX_NEG_PASSES = 40 # Maximum Telnet option exchange iterations during negotiation
56
69
 
57
70
 
@@ -184,6 +197,10 @@ class TN5250Connection:
184
197
  self._ttype_will_sent = False
185
198
  self._negotiated_ttype = "IBM-3477-FG" # screen model reported to IBM i
186
199
 
200
+ # Byte 9 (opcode/var-header length) of the last received TN5250E record.
201
+ # Used to detect header-only Cancel-Invite records (opcode 0x0A).
202
+ self._last_recv_opcode: int = 0x03
203
+
187
204
  # TLS
188
205
  if use_tls and tls_context is None:
189
206
  tls_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
@@ -244,13 +261,61 @@ class TN5250Connection:
244
261
 
245
262
  frame += bytes([TelnetCmd.INTERPRET_AS_COMMAND, TelnetCmd.END_OF_RECORD])
246
263
 
264
+ self._send_frame(frame, description=f"→ {len(data)} payload bytes: {data.hex()}")
265
+
266
+ def _send_frame(self, frame: bytes, *, description: str) -> None:
267
+ """Send a fully-framed (IAC-EOR terminated) record under the send lock."""
268
+ if self._sock is None:
269
+ raise ConnectionError("Not connected")
270
+ log.debug(description)
247
271
  with self._lock:
248
272
  try:
249
273
  self._sock.sendall(frame)
250
274
  except OSError as exc:
251
275
  raise ConnectionError(f"Send failed: {exc}") from exc
252
276
 
253
- log.debug("→ %d payload bytes: %s", len(data), data.hex())
277
+ def send_attn(self) -> None:
278
+ """Send the Attention (Attn) signal to the host.
279
+
280
+ Attn is *not* an AID key. In TN5250E it is a header-only INPUT record
281
+ whose byte 7 carries the Attn flag (``0x40``) with no variable header
282
+ and no payload -- exactly what emulators emit when the PC "Esc" key
283
+ (mapped to Attn) is pressed. On the AS/400 this typically raises the
284
+ System Request / Assist menu. In basic (non-TN5250E) mode the Attn key
285
+ is signalled with a Telnet BREAK (RFC 1205).
286
+ """
287
+ if not self._tn5250e_active:
288
+ # Basic TN5250: the Attn key is signalled with a Telnet BREAK.
289
+ frame = bytes(
290
+ [
291
+ TelnetCmd.INTERPRET_AS_COMMAND,
292
+ TelnetCmd.BREAK,
293
+ TelnetCmd.INTERPRET_AS_COMMAND,
294
+ TelnetCmd.END_OF_RECORD,
295
+ ]
296
+ )
297
+ else:
298
+ # Header-only INPUT record: byte 7 carries the Attn flag.
299
+ frame = _build_tn5250e_frame(req_resp=_TN5250E_ATTN_FLAG, opcode=0x00)
300
+
301
+ self._send_frame(frame, description="Sending Attn signal")
302
+
303
+ def send_cancel_invite_response(self) -> None:
304
+ """Acknowledge a host Cancel-Invite by echoing it back.
305
+
306
+ After the workstation sends Attn, the host replies with a header-only
307
+ Cancel-Invite record (opcode ``0x0A``, no payload) to cancel its
308
+ outstanding read invite. The workstation must echo the identical record
309
+ back; only then does the host run its Attention program (which raises,
310
+ for example, the System Request / Assist menu). Without this ack the
311
+ host stalls and never sends the follow-up screen.
312
+ """
313
+ if not self._tn5250e_active:
314
+ return # cancel-invite handshake only occurs in TN5250E mode
315
+
316
+ # Header-only INPUT record: byte 9 opcode = Cancel-Invite, no payload.
317
+ frame = _build_tn5250e_frame(req_resp=0x00, opcode=_TN5250E_OP_CANCEL_INVITE)
318
+ self._send_frame(frame, description="Sending Cancel-Invite response")
254
319
 
255
320
  def send_save_screen_response(self) -> None:
256
321
  """Send the TN5250E-level Save Screen acknowledgment.
@@ -261,42 +326,16 @@ class TN5250Connection:
261
326
  send). This is a protocol-level exchange with no 5250 data stream
262
327
  payload.
263
328
  """
264
- if self._sock is None:
265
- raise ConnectionError("Not connected")
266
329
  if not self._tn5250e_active:
267
330
  return # save/restore handshake only occurs in TN5250E mode
268
331
 
269
- # 4-byte variable header: [ESC, RESTORE_SCREEN, CC1, CC2.RESET_MDT]
332
+ # 4-byte variable header: [ESC, RESTORE_SCREEN, CC1, CC2.RESET_MDT].
333
+ # byte 9 of the fixed header carries the variable-header length.
270
334
  var_hdr = bytes(
271
335
  [Command.ESC, Command.RESTORE_SCREEN, _SAVE_SCREEN_RESP_CC1, CC2.RESET_MDT]
272
336
  )
273
- total = _TN5250E_HEADER_LEN + len(var_hdr) # 14
274
- frame = (
275
- bytes(
276
- [
277
- (total >> 8) & 0xFF,
278
- total & 0xFF, # record length
279
- _GDS_RECORD_ID_HI,
280
- _GDS_RECORD_ID_LO, # GDS identifier
281
- 0x00,
282
- 0x00, # flags
283
- TN5250EDataType.INPUT, # data type (matches request)
284
- 0x00,
285
- 0x00, # seq / asap-exit
286
- len(var_hdr), # variable header length = 4
287
- ]
288
- )
289
- + var_hdr
290
- )
291
- frame = _iac_escape(frame) + bytes(
292
- [TelnetCmd.INTERPRET_AS_COMMAND, TelnetCmd.END_OF_RECORD]
293
- )
294
- log.debug("Sending Save Screen response")
295
- with self._lock:
296
- try:
297
- self._sock.sendall(frame)
298
- except OSError as exc:
299
- raise ConnectionError(f"Send failed: {exc}") from exc
337
+ frame = _build_tn5250e_frame(req_resp=0x00, opcode=len(var_hdr), var_hdr=var_hdr)
338
+ self._send_frame(frame, description="Sending Save Screen response")
300
339
 
301
340
  def recv_record(self) -> bytes:
302
341
  """Block until a complete record arrives; return the raw 5250 payload."""
@@ -307,6 +346,7 @@ class TN5250Connection:
307
346
  log.debug("Short TN5250E frame (%d bytes), skipping", len(frame))
308
347
  continue
309
348
  hdr = TN5250EHeader.from_bytes(frame)
349
+ self._last_recv_opcode = frame[9] # byte 9: opcode / var_len
310
350
  payload = bytes(frame[_TN5250E_HEADER_LEN:])
311
351
  log.debug(
312
352
  "← TN5250E type=%02X data_type=%02X payload=%d bytes",
@@ -326,6 +366,7 @@ class TN5250Connection:
326
366
  frame_bytes[:2], "big"
327
367
  ) == len(frame_bytes):
328
368
  hdr = TN5250EHeader.from_bytes(frame_bytes)
369
+ self._last_recv_opcode = frame_bytes[9] # byte 9: opcode
329
370
  var_hdr_len = frame_bytes[
330
371
  9
331
372
  ] # RFC 2877 variable-header length field
@@ -369,6 +410,24 @@ class TN5250Connection:
369
410
  """True if TN5250E enhanced mode was successfully negotiated."""
370
411
  return self._tn5250e_active
371
412
 
413
+ @property
414
+ def last_recv_opcode(self) -> int:
415
+ """Return byte 9 (opcode/var_len) from the last received TN5250E frame.
416
+
417
+ Read-poll responses must echo this value back to the server.
418
+ """
419
+ return self._last_recv_opcode
420
+
421
+ @property
422
+ def last_recv_was_cancel_invite(self) -> bool:
423
+ """True if the last received TN5250E record was a Cancel-Invite.
424
+
425
+ The host sends a header-only Cancel-Invite (opcode ``0x0A``, no 5250
426
+ payload) after the workstation presses Attn. It must be acknowledged
427
+ via :meth:`send_cancel_invite_response`.
428
+ """
429
+ return self._last_recv_opcode == _TN5250E_OP_CANCEL_INVITE
430
+
372
431
  # ------------------------------------------------------------------
373
432
  # Internal — Telnet negotiation
374
433
  # ------------------------------------------------------------------
@@ -698,3 +757,40 @@ class TN5250Connection:
698
757
  def _iac_escape(data: bytes) -> bytes:
699
758
  """Double any IAC (0xFF) bytes in *data* for safe Telnet transmission."""
700
759
  return data.replace(b"\xff", b"\xff\xff")
760
+
761
+
762
+ def _build_tn5250e_frame(*, req_resp: int, opcode: int, var_hdr: bytes = b"") -> bytes:
763
+ """Build an IAC-EOR-terminated TN5250E frame with no 5250 payload.
764
+
765
+ Produces the fixed 10-byte header (RFC 2877 §3.3) followed by an optional
766
+ variable header, IAC-escapes it, and appends the IAC-EOR record terminator.
767
+ Used for the header-only control records: Attn, Cancel-Invite response and
768
+ Save Screen response.
769
+
770
+ Parameters
771
+ ----------
772
+ req_resp:
773
+ Byte 7 (request/response flags) — e.g. the Attn flag.
774
+ opcode:
775
+ Byte 9 (operation code / variable-header length).
776
+ var_hdr:
777
+ Optional variable-header bytes appended after the fixed header.
778
+ """
779
+ total = _TN5250E_HEADER_LEN + len(var_hdr)
780
+ header = bytes(
781
+ [
782
+ (total >> 8) & 0xFF,
783
+ total & 0xFF, # 0-1: logical record length
784
+ _GDS_RECORD_ID_HI,
785
+ _GDS_RECORD_ID_LO, # 2-3: GDS identifier
786
+ 0x00,
787
+ 0x00, # 4-5: reserved
788
+ TN5250EDataType.INPUT, # 6: data type (workstation → host)
789
+ req_resp, # 7: request/response flags
790
+ 0x00, # 8: error recovery
791
+ opcode, # 9: opcode / variable-header length
792
+ ]
793
+ )
794
+ return _iac_escape(header + var_hdr) + bytes(
795
+ [TelnetCmd.INTERPRET_AS_COMMAND, TelnetCmd.END_OF_RECORD]
796
+ )
@@ -217,6 +217,20 @@ class Terminal:
217
217
  """Send an AID key by name, e.g. ``"Enter"``, ``"F3"``, ``"Clear"``."""
218
218
  self.send_key(AID.from_name(name), wait=wait)
219
219
 
220
+ def send_attn(self, *, wait: bool = True) -> None:
221
+ """Send the Attention (Attn) signal to the host.
222
+
223
+ Attn is a protocol-level attention indication -- *not* an AID key -- and
224
+ is what 5250 emulators send for the PC "Esc" key. On the AS/400 it
225
+ typically raises the System Request / Assist menu. By default this
226
+ blocks until the host responds; pass ``wait=False`` to return
227
+ immediately after sending.
228
+ """
229
+ self._conn.send_attn()
230
+ self._screen._keyboard_locked = True
231
+ if wait:
232
+ self._recv_and_apply()
233
+
220
234
  def type_into(
221
235
  self,
222
236
  field: Field | FieldProxy,
@@ -517,6 +531,14 @@ class Terminal:
517
531
 
518
532
  def _recv_and_apply(self) -> None:
519
533
  received_payload = self._conn.recv_record()
534
+ # A header-only Cancel-Invite (no 5250 payload) is the host's reply to
535
+ # our Attn: it cancels the outstanding read invite. We must echo it back
536
+ # to let the host run its Attention program (e.g. the Assist menu);
537
+ # otherwise the host stalls and never sends the follow-up screen.
538
+ if not received_payload and self._conn.last_recv_was_cancel_invite:
539
+ log.debug("Received Cancel-Invite; echoing acknowledgment")
540
+ self._conn.send_cancel_invite_response()
541
+ return
520
542
  parsed_events = list(self._parser.parse(received_payload))
521
543
  # If this record has screen content, the pending save is no longer back-to-back
522
544
  for ev in parsed_events:
@@ -13,7 +13,7 @@ wheels = [
13
13
 
14
14
  [[package]]
15
15
  name = "ibm5250"
16
- version = "0.1.0.dev1"
16
+ version = "0.1.0.dev3"
17
17
  source = { editable = "." }
18
18
 
19
19
  [package.dev-dependencies]
File without changes
File without changes
File without changes