From 6f7e86b25c876cab34aa29f0296bbd93f36b7b81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hans=20H=C3=BCbner?= Date: Thu, 12 Jan 2023 08:44:49 +0100 Subject: [PATCH 01/12] Add mapping for PF keys on 3278-2 with APL keyboard --- oec/keymap_3278_typewriter.py | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/oec/keymap_3278_typewriter.py b/oec/keymap_3278_typewriter.py index 4b30298..9bcbd7e 100644 --- a/oec/keymap_3278_typewriter.py +++ b/oec/keymap_3278_typewriter.py @@ -93,7 +93,21 @@ 14: Key.UP, 19: Key.DOWN, 22: Key.LEFT, - 26: Key.RIGHT + 26: Key.RIGHT, + + # PF keys on APL keyboard + 64: Key.PF1, + 65: Key.PF2, + 66: Key.PF3, + 67: Key.PF4, + 68: Key.PF5, + 69: Key.PF6, + 70: Key.PF7, + 71: Key.PF8, + 72: Key.PF9, + 73: Key.PF10, + 74: Key.PF11, + 75: Key.PF12 } KEYMAP_SHIFT = { @@ -153,7 +167,21 @@ 108: Key.UPPER_M, 51: Key.COMMA, # TODO: Confirm this mapping 50: Key.CENTER_PERIOD, - 20: Key.QUESTION + 20: Key.QUESTION, + + # PF keys on APL keyboard + 64: Key.PF13, + 65: Key.PF14, + 66: Key.PF15, + 67: Key.PF16, + 68: Key.PF17, + 69: Key.PF18, + 70: Key.PF19, + 71: Key.PF20, + 72: Key.PF21, + 73: Key.PF22, + 74: Key.PF23, + 75: Key.PF24 } KEYMAP_ALT = { From 7a675180a00da559d110e48d8207bb1607ae913c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hans=20H=C3=BCbner?= Date: Sun, 29 Mar 2026 10:47:15 +0200 Subject: [PATCH 02/12] SSL/TLS support Co-Authored-By: Claude Opus 4.6 (1M context) --- oec/__main__.py | 2 +- oec/args.py | 7 +++++++ oec/tn3270.py | 21 +++++++++++++++++++-- tests/test_args.py | 18 ++++++++++++++++++ 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/oec/__main__.py b/oec/__main__.py index 2b33519..dac0a30 100644 --- a/oec/__main__.py +++ b/oec/__main__.py @@ -75,7 +75,7 @@ def _create_device(args, interface, device_address, _poll_response): def _create_session(args, device): if args.emulator == 'tn3270': - return TN3270Session(device, args.host, args.port, args.device_names, args.character_encoding, args.tn3270e_profile) + return TN3270Session(device, args.host, args.port, args.device_names, args.character_encoding, args.tn3270e_profile, args.ssl, args.ssl_no_verify) if args.emulator == 'vt100' and IS_VT100_AVAILABLE: host_command = [args.command, *args.command_args] diff --git a/oec/args.py b/oec/args.py index 0b02183..ea7979a 100644 --- a/oec/args.py +++ b/oec/args.py @@ -28,6 +28,13 @@ def parse_args(args, is_vt100_available): dest='character_encoding', type=get_character_encoding, help='host EBCDIC code page') + tn3270_parser.add_argument('--ssl', action='store_true', default=False, + help='enable SSL/TLS') + + tn3270_parser.add_argument('--ssl-no-verify', action='store_true', default=False, + dest='ssl_no_verify', + help='disable SSL/TLS certificate verification') + tn3270_parser.add_argument('--tn3270e', choices=['off', 'basic', 'default'], metavar='profile', default='default', dest='tn3270e_profile', diff --git a/oec/tn3270.py b/oec/tn3270.py index 6827d80..bc4eb4d 100644 --- a/oec/tn3270.py +++ b/oec/tn3270.py @@ -4,6 +4,7 @@ """ import logging +import ssl from tn3270 import Telnet, TN3270EFunction, Emulator, AttributeCell, CharacterCell, AID, Color, \ Highlight, OperatorError, ProtectedCellOperatorError, FieldOverflowOperatorError from tn3270.ebcdic import DUP, FM @@ -47,7 +48,7 @@ class TN3270Session(Session): """TN3270 session.""" - def __init__(self, terminal, host, port, device_names, character_encoding, tn3270e_profile): + def __init__(self, terminal, host, port, device_names, character_encoding, tn3270e_profile, ssl_enabled=False, ssl_no_verify=False): super().__init__(terminal) self.logger = logging.getLogger(__name__) @@ -57,6 +58,8 @@ def __init__(self, terminal, host, port, device_names, character_encoding, tn327 self.device_names = device_names self.character_encoding = character_encoding self.tn3270e_profile = tn3270e_profile + self.ssl_enabled = ssl_enabled + self.ssl_no_verify = ssl_no_verify self.telnet = None self.emulator = None @@ -196,7 +199,21 @@ def _connect_host(self): self.telnet = Telnet(terminal_type, **tn3270e_args) - self.telnet.open(self.host, self.port, self.device_names) + ssl_args = {} + + if self.ssl_enabled: + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + + if self.ssl_no_verify: + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + else: + ssl_context.load_default_certs() + + ssl_args['ssl_context'] = ssl_context + ssl_args['ssl_server_hostname'] = self.host + + self.telnet.open(self.host, self.port, self.device_names, **ssl_args) if self.telnet.is_tn3270e_negotiated: self.logger.info(f'TN3270E mode negotiated: Device Type = {self.telnet.device_type}, Device Name = {self.telnet.device_name}, Functions = {self.telnet.tn3270e_functions}') diff --git a/tests/test_args.py b/tests/test_args.py index 0e5cdf9..e75b24c 100644 --- a/tests/test_args.py +++ b/tests/test_args.py @@ -85,3 +85,21 @@ def test_tn3270_invalid_deprecated_port(self): self.parser_error.assert_called_once() self.assertEqual(self.parser_error.call_args.args[0], f'argument port: invalid port: {port}') + + def test_tn3270_ssl_default(self): + args = parse_args(['/dev/ttyACM0', 'tn3270', 'host'], False) + + self.assertFalse(args.ssl) + self.assertFalse(args.ssl_no_verify) + + def test_tn3270_ssl(self): + args = parse_args(['/dev/ttyACM0', 'tn3270', '--ssl', 'host:992'], False) + + self.assertTrue(args.ssl) + self.assertFalse(args.ssl_no_verify) + + def test_tn3270_ssl_no_verify(self): + args = parse_args(['/dev/ttyACM0', 'tn3270', '--ssl', '--ssl-no-verify', 'host:992'], False) + + self.assertTrue(args.ssl) + self.assertTrue(args.ssl_no_verify) From b298d71e814bbb2041ebb7d97464afcfab6d4226 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hans=20H=C3=BCbner?= Date: Sun, 29 Mar 2026 11:12:49 +0200 Subject: [PATCH 03/12] STARTTLS support Co-Authored-By: Claude Opus 4.6 (1M context) --- oec/__main__.py | 2 +- oec/args.py | 9 +++++++-- oec/tn3270.py | 13 +++++++++---- requirements.txt | 2 +- tests/test_args.py | 14 ++++++++++++++ 5 files changed, 32 insertions(+), 8 deletions(-) diff --git a/oec/__main__.py b/oec/__main__.py index dac0a30..f290b19 100644 --- a/oec/__main__.py +++ b/oec/__main__.py @@ -75,7 +75,7 @@ def _create_device(args, interface, device_address, _poll_response): def _create_session(args, device): if args.emulator == 'tn3270': - return TN3270Session(device, args.host, args.port, args.device_names, args.character_encoding, args.tn3270e_profile, args.ssl, args.ssl_no_verify) + return TN3270Session(device, args.host, args.port, args.device_names, args.character_encoding, args.tn3270e_profile, args.ssl, args.starttls, args.ssl_no_verify) if args.emulator == 'vt100' and IS_VT100_AVAILABLE: host_command = [args.command, *args.command_args] diff --git a/oec/args.py b/oec/args.py index ea7979a..e870325 100644 --- a/oec/args.py +++ b/oec/args.py @@ -28,8 +28,13 @@ def parse_args(args, is_vt100_available): dest='character_encoding', type=get_character_encoding, help='host EBCDIC code page') - tn3270_parser.add_argument('--ssl', action='store_true', default=False, - help='enable SSL/TLS') + ssl_group = tn3270_parser.add_mutually_exclusive_group() + + ssl_group.add_argument('--ssl', action='store_true', default=False, + help='enable implicit SSL/TLS') + + ssl_group.add_argument('--starttls', action='store_true', default=False, + help='enable STARTTLS negotiation') tn3270_parser.add_argument('--ssl-no-verify', action='store_true', default=False, dest='ssl_no_verify', diff --git a/oec/tn3270.py b/oec/tn3270.py index bc4eb4d..b18cf8f 100644 --- a/oec/tn3270.py +++ b/oec/tn3270.py @@ -48,7 +48,7 @@ class TN3270Session(Session): """TN3270 session.""" - def __init__(self, terminal, host, port, device_names, character_encoding, tn3270e_profile, ssl_enabled=False, ssl_no_verify=False): + def __init__(self, terminal, host, port, device_names, character_encoding, tn3270e_profile, ssl_enabled=False, starttls_enabled=False, ssl_no_verify=False): super().__init__(terminal) self.logger = logging.getLogger(__name__) @@ -59,6 +59,7 @@ def __init__(self, terminal, host, port, device_names, character_encoding, tn327 self.character_encoding = character_encoding self.tn3270e_profile = tn3270e_profile self.ssl_enabled = ssl_enabled + self.starttls_enabled = starttls_enabled self.ssl_no_verify = ssl_no_verify self.telnet = None @@ -201,7 +202,7 @@ def _connect_host(self): ssl_args = {} - if self.ssl_enabled: + if self.ssl_enabled or self.starttls_enabled: ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) if self.ssl_no_verify: @@ -210,8 +211,12 @@ def _connect_host(self): else: ssl_context.load_default_certs() - ssl_args['ssl_context'] = ssl_context - ssl_args['ssl_server_hostname'] = self.host + if self.ssl_enabled: + ssl_args['ssl_context'] = ssl_context + ssl_args['ssl_server_hostname'] = self.host + else: + ssl_args['starttls_ssl_context'] = ssl_context + ssl_args['starttls_server_hostname'] = self.host self.telnet.open(self.host, self.port, self.device_names, **ssl_args) diff --git a/requirements.txt b/requirements.txt index 6c98492..ac23bf3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ ptyprocess==0.7.0 pycoax==0.11.2 pyserial==3.5 pyte==0.8.1 -pytn3270==0.16.0 +pytn3270 @ git+https://github.com/hanshuebner/pytn3270.git@master sliplib==0.6.2 sortedcontainers==2.4.0 telnetlib3==2.0.4 diff --git a/tests/test_args.py b/tests/test_args.py index e75b24c..e901245 100644 --- a/tests/test_args.py +++ b/tests/test_args.py @@ -90,12 +90,14 @@ def test_tn3270_ssl_default(self): args = parse_args(['/dev/ttyACM0', 'tn3270', 'host'], False) self.assertFalse(args.ssl) + self.assertFalse(args.starttls) self.assertFalse(args.ssl_no_verify) def test_tn3270_ssl(self): args = parse_args(['/dev/ttyACM0', 'tn3270', '--ssl', 'host:992'], False) self.assertTrue(args.ssl) + self.assertFalse(args.starttls) self.assertFalse(args.ssl_no_verify) def test_tn3270_ssl_no_verify(self): @@ -103,3 +105,15 @@ def test_tn3270_ssl_no_verify(self): self.assertTrue(args.ssl) self.assertTrue(args.ssl_no_verify) + + def test_tn3270_starttls(self): + args = parse_args(['/dev/ttyACM0', 'tn3270', '--starttls', 'host:23'], False) + + self.assertFalse(args.ssl) + self.assertTrue(args.starttls) + + def test_tn3270_starttls_no_verify(self): + args = parse_args(['/dev/ttyACM0', 'tn3270', '--starttls', '--ssl-no-verify', 'host:23'], False) + + self.assertTrue(args.starttls) + self.assertTrue(args.ssl_no_verify) From 56cac50e4812ca8ccfca208bb531a8afd30ad85d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hans=20H=C3=BCbner?= Date: Sun, 29 Mar 2026 11:20:07 +0200 Subject: [PATCH 04/12] STARTTLS enabled by default, add --no-starttls flag Co-Authored-By: Claude Opus 4.6 (1M context) --- oec/__main__.py | 2 +- oec/args.py | 11 +++++------ oec/tn3270.py | 7 ++++--- tests/test_args.py | 15 ++++----------- 4 files changed, 14 insertions(+), 21 deletions(-) diff --git a/oec/__main__.py b/oec/__main__.py index f290b19..7efb7a6 100644 --- a/oec/__main__.py +++ b/oec/__main__.py @@ -75,7 +75,7 @@ def _create_device(args, interface, device_address, _poll_response): def _create_session(args, device): if args.emulator == 'tn3270': - return TN3270Session(device, args.host, args.port, args.device_names, args.character_encoding, args.tn3270e_profile, args.ssl, args.starttls, args.ssl_no_verify) + return TN3270Session(device, args.host, args.port, args.device_names, args.character_encoding, args.tn3270e_profile, args.ssl, args.no_starttls, args.ssl_no_verify) if args.emulator == 'vt100' and IS_VT100_AVAILABLE: host_command = [args.command, *args.command_args] diff --git a/oec/args.py b/oec/args.py index e870325..6abb899 100644 --- a/oec/args.py +++ b/oec/args.py @@ -28,13 +28,12 @@ def parse_args(args, is_vt100_available): dest='character_encoding', type=get_character_encoding, help='host EBCDIC code page') - ssl_group = tn3270_parser.add_mutually_exclusive_group() + tn3270_parser.add_argument('--ssl', action='store_true', default=False, + help='enable implicit SSL/TLS') - ssl_group.add_argument('--ssl', action='store_true', default=False, - help='enable implicit SSL/TLS') - - ssl_group.add_argument('--starttls', action='store_true', default=False, - help='enable STARTTLS negotiation') + tn3270_parser.add_argument('--no-starttls', action='store_true', default=False, + dest='no_starttls', + help='disable STARTTLS negotiation') tn3270_parser.add_argument('--ssl-no-verify', action='store_true', default=False, dest='ssl_no_verify', diff --git a/oec/tn3270.py b/oec/tn3270.py index b18cf8f..0d97d77 100644 --- a/oec/tn3270.py +++ b/oec/tn3270.py @@ -48,7 +48,7 @@ class TN3270Session(Session): """TN3270 session.""" - def __init__(self, terminal, host, port, device_names, character_encoding, tn3270e_profile, ssl_enabled=False, starttls_enabled=False, ssl_no_verify=False): + def __init__(self, terminal, host, port, device_names, character_encoding, tn3270e_profile, ssl_enabled=False, no_starttls=False, ssl_no_verify=False): super().__init__(terminal) self.logger = logging.getLogger(__name__) @@ -59,7 +59,7 @@ def __init__(self, terminal, host, port, device_names, character_encoding, tn327 self.character_encoding = character_encoding self.tn3270e_profile = tn3270e_profile self.ssl_enabled = ssl_enabled - self.starttls_enabled = starttls_enabled + self.no_starttls = no_starttls self.ssl_no_verify = ssl_no_verify self.telnet = None @@ -201,8 +201,9 @@ def _connect_host(self): self.telnet = Telnet(terminal_type, **tn3270e_args) ssl_args = {} + starttls_enabled = not self.ssl_enabled and not self.no_starttls - if self.ssl_enabled or self.starttls_enabled: + if self.ssl_enabled or starttls_enabled: ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) if self.ssl_no_verify: diff --git a/tests/test_args.py b/tests/test_args.py index e901245..095e5a5 100644 --- a/tests/test_args.py +++ b/tests/test_args.py @@ -90,14 +90,13 @@ def test_tn3270_ssl_default(self): args = parse_args(['/dev/ttyACM0', 'tn3270', 'host'], False) self.assertFalse(args.ssl) - self.assertFalse(args.starttls) + self.assertFalse(args.no_starttls) self.assertFalse(args.ssl_no_verify) def test_tn3270_ssl(self): args = parse_args(['/dev/ttyACM0', 'tn3270', '--ssl', 'host:992'], False) self.assertTrue(args.ssl) - self.assertFalse(args.starttls) self.assertFalse(args.ssl_no_verify) def test_tn3270_ssl_no_verify(self): @@ -106,14 +105,8 @@ def test_tn3270_ssl_no_verify(self): self.assertTrue(args.ssl) self.assertTrue(args.ssl_no_verify) - def test_tn3270_starttls(self): - args = parse_args(['/dev/ttyACM0', 'tn3270', '--starttls', 'host:23'], False) + def test_tn3270_no_starttls(self): + args = parse_args(['/dev/ttyACM0', 'tn3270', '--no-starttls', 'host:23'], False) self.assertFalse(args.ssl) - self.assertTrue(args.starttls) - - def test_tn3270_starttls_no_verify(self): - args = parse_args(['/dev/ttyACM0', 'tn3270', '--starttls', '--ssl-no-verify', 'host:23'], False) - - self.assertTrue(args.starttls) - self.assertTrue(args.ssl_no_verify) + self.assertTrue(args.no_starttls) From 7b00eae8a7c7b0c777d3dc15150ccb7765edf5a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hans=20H=C3=BCbner?= Date: Tue, 7 Apr 2026 07:19:18 +0200 Subject: [PATCH 05/12] Fix slow screen updates with SSL/TLS connections SSL sockets buffer decrypted data internally, but select() only monitors the underlying OS file descriptor. When a TLS record spans multiple recv() calls, the remaining data sits in SSL's internal buffer invisible to select(), causing long delays until the next network event. Check SSLSocket.pending() before falling back to select() so buffered SSL data is processed immediately. Co-Authored-By: Claude Opus 4.6 (1M context) --- oec/controller.py | 7 +++++++ oec/session.py | 3 +++ oec/tn3270.py | 4 ++++ 3 files changed, 14 insertions(+) diff --git a/oec/controller.py b/oec/controller.py index 551e75b..7c7a5d8 100644 --- a/oec/controller.py +++ b/oec/controller.py @@ -188,6 +188,13 @@ def _select_sessions(self, duration): if not self.session_selector.get_map(): return [] + # Check for sessions with SSL pending data first - select() won't + # report these as readable since the data is buffered in the SSL layer. + pending_sessions = [key.fileobj for key in self.session_selector.get_map().values() if key.fileobj.has_pending_data()] + + if pending_sessions: + return pending_sessions + selected = self.session_selector.select(duration) return [key.fileobj for (key, _) in selected] diff --git a/oec/session.py b/oec/session.py index a876f32..35e0b19 100644 --- a/oec/session.py +++ b/oec/session.py @@ -11,6 +11,9 @@ def terminate(self): def fileno(self): raise NotImplementedError + def has_pending_data(self): + return False + def handle_host(self): raise NotImplementedError diff --git a/oec/tn3270.py b/oec/tn3270.py index 0d97d77..5592828 100644 --- a/oec/tn3270.py +++ b/oec/tn3270.py @@ -98,6 +98,10 @@ def terminate(self): def fileno(self): return self.emulator.stream.socket.fileno() + def has_pending_data(self): + sock = self.emulator.stream.socket + return isinstance(sock, ssl.SSLSocket) and sock.pending() > 0 + def handle_host(self): try: if not self.emulator.update(timeout=0): From c10f76d8e56293e39df52092acc9be427e9fc5d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hans=20H=C3=BCbner?= Date: Tue, 7 Apr 2026 07:30:48 +0200 Subject: [PATCH 06/12] German umlaut support for multinational character set terminals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ä/ö/ü/Ä/Ö/Ü mappings to CHAR_MAP using the GE/APL character plane (0x50-0x54 lowercase, 0x70-0x74 uppercase). Co-Authored-By: Claude Opus 4.6 (1M context) --- oec/display.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/oec/display.py b/oec/display.py index aa6e49d..d1d5b2a 100644 --- a/oec/display.py +++ b/oec/display.py @@ -324,6 +324,14 @@ def _get_dirty_ranges(self): return [(self.dirty[0], self.dirty[-1])] CHAR_MAP = { + # GE/APL plane (0x40-0x7F) - multinational accented characters + 'ä': 0x50, + 'ö': 0x53, + 'ü': 0x54, + 'Ä': 0x70, + 'Ö': 0x73, + 'Ü': 0x74, + '>': 0x08, '<': 0x09, '[': 0x0a, From a0502f197b5ed77ba780bf286d1ec6f235212ac8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hans=20H=C3=BCbner?= Date: Tue, 7 Apr 2026 07:31:29 +0200 Subject: [PATCH 07/12] Add udev rule script for coax interface symlink Creates /dev/ttyCoax symlink based on USB serial number. Co-Authored-By: Claude Opus 4.6 (1M context) --- setup-udev.sh | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100755 setup-udev.sh diff --git a/setup-udev.sh b/setup-udev.sh new file mode 100755 index 0000000..9b76fc4 --- /dev/null +++ b/setup-udev.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -euo pipefail + +RULE='SUBSYSTEM=="tty", ATTRS{serial}=="e6613852831c9e32", SYMLINK+="ttyCoax"' +RULE_FILE="/etc/udev/rules.d/99-coax-interface.rules" + +echo "$RULE" | sudo tee "$RULE_FILE" > /dev/null +sudo udevadm control --reload-rules +sudo udevadm trigger --subsystem-match=tty + +echo "Rule installed at $RULE_FILE" +echo "Symlink /dev/ttyCoax should now be active:" +ls -l /dev/ttyCoax From 320d858e5d90f18952d56783b5ddb8a41ff2f71f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hans=20H=C3=BCbner?= Date: Tue, 7 Apr 2026 07:56:43 +0200 Subject: [PATCH 08/12] German keyboard layout support (--keyboard-language de) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add --keyboard-language option (us/de) for 3278 keyboards. The DE variant remaps the number row shifted keys, umlauts on ä/ö/ü keys, ß, § and other German-specific key positions. Co-Authored-By: Claude Opus 4.6 (1M context) --- oec/__main__.py | 10 ++++-- oec/args.py | 4 +++ oec/keyboard.py | 16 ++++++++- oec/keymap_3278_typewriter_de.py | 62 ++++++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 oec/keymap_3278_typewriter_de.py diff --git a/oec/__main__.py b/oec/__main__.py index 7efb7a6..0d3fc78 100644 --- a/oec/__main__.py +++ b/oec/__main__.py @@ -20,6 +20,7 @@ IS_VT100_AVAILABLE = True from .keymap_3278_typewriter import KEYMAP as KEYMAP_3278_TYPEWRITER +from .keymap_3278_typewriter_de import KEYMAP as KEYMAP_3278_TYPEWRITER_DE from .keymap_ibm_typewriter import KEYMAP as KEYMAP_IBM_TYPEWRITER from .keymap_ibm_enhanced import KEYMAP as KEYMAP_IBM_ENHANCED @@ -27,9 +28,14 @@ logger = logging.getLogger('oec.main') -def _get_keymap(_args, keyboard_description): +KEYMAP_3278_LANGUAGE = { + 'us': KEYMAP_3278_TYPEWRITER, + 'de': KEYMAP_3278_TYPEWRITER_DE +} + +def _get_keymap(args, keyboard_description): if keyboard_description.startswith('3278'): - return KEYMAP_3278_TYPEWRITER + return KEYMAP_3278_LANGUAGE.get(args.keyboard_language, KEYMAP_3278_TYPEWRITER) if keyboard_description.startswith('IBM-TYPEWRITER'): return KEYMAP_IBM_TYPEWRITER diff --git a/oec/args.py b/oec/args.py index 6abb899..5d374e4 100644 --- a/oec/args.py +++ b/oec/args.py @@ -14,6 +14,10 @@ def parse_args(args, is_vt100_available): parser.add_argument('serial_port', help='serial port') + parser.add_argument('--keyboard-language', choices=['us', 'de'], default='us', + dest='keyboard_language', + help='keyboard national layout (default: us)') + subparsers = parser.add_subparsers(dest='emulator', required=True, description='emulator') diff --git a/oec/keyboard.py b/oec/keyboard.py index 11e2618..a3bd170 100644 --- a/oec/keyboard.py +++ b/oec/keyboard.py @@ -228,6 +228,17 @@ class Key(Enum): SLASH = ord('/') QUESTION = ord('?') + # German + ESZETT = ord('ß') + SECTION = ord('§') + CARET = ord('^') + LOWER_A_UMLAUT = ord('ä') + UPPER_A_UMLAUT = ord('Ä') + LOWER_O_UMLAUT = ord('ö') + UPPER_O_UMLAUT = ord('Ö') + LOWER_U_UMLAUT = ord('ü') + UPPER_U_UMLAUT = ord('Ü') + KEY_UPPER_MAP = { Key.LOWER_A: Key.UPPER_A, Key.LOWER_B: Key.UPPER_B, @@ -254,7 +265,10 @@ class Key(Enum): Key.LOWER_W: Key.UPPER_W, Key.LOWER_X: Key.UPPER_X, Key.LOWER_Y: Key.UPPER_Y, - Key.LOWER_Z: Key.UPPER_Z + Key.LOWER_Z: Key.UPPER_Z, + Key.LOWER_A_UMLAUT: Key.UPPER_A_UMLAUT, + Key.LOWER_O_UMLAUT: Key.UPPER_O_UMLAUT, + Key.LOWER_U_UMLAUT: Key.UPPER_U_UMLAUT } KEY_LOWER_MAP = {upper_key: lower_key for lower_key, upper_key in KEY_UPPER_MAP.items()} diff --git a/oec/keymap_3278_typewriter_de.py b/oec/keymap_3278_typewriter_de.py new file mode 100644 index 0000000..dd3af6e --- /dev/null +++ b/oec/keymap_3278_typewriter_de.py @@ -0,0 +1,62 @@ +""" +oec.keymap_3278_typewriter_de +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +""" + +from .keyboard import Key, Keymap +from .keymap_3278_typewriter import KEYMAP_DEFAULT as _US_DEFAULT, KEYMAP_SHIFT as _US_SHIFT, \ + KEYMAP_ALT, MODIFIER_RELEASE_MAP + +KEYMAP_DEFAULT = { + **_US_DEFAULT, + + # First Row + 48: Key.ESZETT, # was MINUS + 17: Key.SINGLE_QUOTE, # was EQUAL + + # Second Row + 27: Key.LOWER_U_UMLAUT, # was CENT + 21: Key.PLUS, # was BACKSLASH + + # Third Row + 126: Key.LOWER_O_UMLAUT, # was SEMICOLON + 18: Key.LOWER_A_UMLAUT, # was SINGLE_QUOTE + 15: Key.HASH, # was LEFT_BRACE + + # Fourth Row + 51: Key.COMMA, # unchanged + 50: Key.PERIOD, # unchanged + 20: Key.MINUS, # was SLASH +} + +KEYMAP_SHIFT = { + **_US_SHIFT, + + # First Row - number row + 33: Key.EXCLAMATION, # was BAR + 34: Key.DOUBLE_QUOTE, # was AT + 35: Key.SECTION, # was HASH + 38: Key.AMPERSAND, # was NOT + 39: Key.SLASH, # was AMPERSAND + 40: Key.LEFT_PAREN, # was ASTERISK + 41: Key.RIGHT_PAREN, # was LEFT_PAREN + 32: Key.EQUAL, # was RIGHT_PAREN + 48: Key.QUESTION, # was UNDERSCORE + 17: Key.BACKTICK, # was PLUS + + # Second Row + 27: Key.UPPER_U_UMLAUT, # was EXCLAMATION + 21: Key.ASTERISK, # was BROKEN_BAR + + # Third Row + 126: Key.UPPER_O_UMLAUT, # was COLON + 18: Key.UPPER_A_UMLAUT, # was DOUBLE_QUOTE + 15: Key.CARET, # was RIGHT_BRACE + + # Fourth Row + 51: Key.SEMICOLON, # was COMMA + 50: Key.COLON, # was CENTER_PERIOD + 20: Key.UNDERSCORE, # was QUESTION +} + +KEYMAP = Keymap('3278 Typewriter (DE)', KEYMAP_DEFAULT, KEYMAP_SHIFT, KEYMAP_ALT, MODIFIER_RELEASE_MAP) From 486719466bd6c362c7cafb580a6c0bc7d8b1b9c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hans=20H=C3=BCbner?= Date: Tue, 7 Apr 2026 07:59:30 +0200 Subject: [PATCH 09/12] Swap Y and Z in German keyboard layout Co-Authored-By: Claude Opus 4.6 (1M context) --- oec/keymap_3278_typewriter_de.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/oec/keymap_3278_typewriter_de.py b/oec/keymap_3278_typewriter_de.py index dd3af6e..8b193d8 100644 --- a/oec/keymap_3278_typewriter_de.py +++ b/oec/keymap_3278_typewriter_de.py @@ -15,6 +15,7 @@ 17: Key.SINGLE_QUOTE, # was EQUAL # Second Row + 120: Key.LOWER_Z, # was LOWER_Y 27: Key.LOWER_U_UMLAUT, # was CENT 21: Key.PLUS, # was BACKSLASH @@ -24,6 +25,7 @@ 15: Key.HASH, # was LEFT_BRACE # Fourth Row + 121: Key.LOWER_Y, # was LOWER_Z 51: Key.COMMA, # unchanged 50: Key.PERIOD, # unchanged 20: Key.MINUS, # was SLASH @@ -45,6 +47,7 @@ 17: Key.BACKTICK, # was PLUS # Second Row + 120: Key.UPPER_Z, # was UPPER_Y 27: Key.UPPER_U_UMLAUT, # was EXCLAMATION 21: Key.ASTERISK, # was BROKEN_BAR @@ -54,6 +57,7 @@ 15: Key.CARET, # was RIGHT_BRACE # Fourth Row + 121: Key.UPPER_Y, # was UPPER_Z 51: Key.SEMICOLON, # was COMMA 50: Key.COLON, # was CENTER_PERIOD 20: Key.UNDERSCORE, # was QUESTION From e4187d2cb9236f1d5e65a7961355e617c533f6b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hans=20H=C3=BCbner?= Date: Tue, 7 Apr 2026 17:12:44 +0200 Subject: [PATCH 10/12] Add --clicker option to enable keyboard clicker on startup Co-Authored-By: Claude Opus 4.6 (1M context) --- oec/__main__.py | 3 +++ oec/args.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/oec/__main__.py b/oec/__main__.py index 0d3fc78..8546f00 100644 --- a/oec/__main__.py +++ b/oec/__main__.py @@ -77,6 +77,9 @@ def _create_device(args, interface, device_address, _poll_response): terminal = Terminal(interface, device_address, terminal_id, extended_id, features, keymap) + if args.clicker: + terminal.keyboard.clicker = True + return terminal def _create_session(args, device): diff --git a/oec/args.py b/oec/args.py index 5d374e4..10c7464 100644 --- a/oec/args.py +++ b/oec/args.py @@ -18,6 +18,9 @@ def parse_args(args, is_vt100_available): dest='keyboard_language', help='keyboard national layout (default: us)') + parser.add_argument('--clicker', action='store_true', default=False, + help='enable keyboard clicker') + subparsers = parser.add_subparsers(dest='emulator', required=True, description='emulator') From 66f57ad1f52f9c3d1c1efe6acdc5e92bb1318190 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hans=20H=C3=BCbner?= Date: Wed, 8 Apr 2026 07:02:05 +0200 Subject: [PATCH 11/12] Add status line indicators for encryption and hostname Display encryption status and hostname on the terminal status line during TN3270 sessions. Add --no-hostname-status option to suppress the hostname display. Pad OEC indicator to full status line width. Co-Authored-By: Claude Opus 4.6 (1M context) --- oec/__main__.py | 2 +- oec/args.py | 4 ++++ oec/terminal.py | 5 +++-- oec/tn3270.py | 25 ++++++++++++++++++++++++- tests/test_terminal.py | 1 + 5 files changed, 33 insertions(+), 4 deletions(-) diff --git a/oec/__main__.py b/oec/__main__.py index 8546f00..c0f834b 100644 --- a/oec/__main__.py +++ b/oec/__main__.py @@ -84,7 +84,7 @@ def _create_device(args, interface, device_address, _poll_response): def _create_session(args, device): if args.emulator == 'tn3270': - return TN3270Session(device, args.host, args.port, args.device_names, args.character_encoding, args.tn3270e_profile, args.ssl, args.no_starttls, args.ssl_no_verify) + return TN3270Session(device, args.host, args.port, args.device_names, args.character_encoding, args.tn3270e_profile, args.ssl, args.no_starttls, args.ssl_no_verify, args.no_hostname_status) if args.emulator == 'vt100' and IS_VT100_AVAILABLE: host_command = [args.command, *args.command_args] diff --git a/oec/args.py b/oec/args.py index 10c7464..e9733b1 100644 --- a/oec/args.py +++ b/oec/args.py @@ -46,6 +46,10 @@ def parse_args(args, is_vt100_available): dest='ssl_no_verify', help='disable SSL/TLS certificate verification') + tn3270_parser.add_argument('--no-hostname-status', action='store_true', default=False, + dest='no_hostname_status', + help='do not display host name on status line') + tn3270_parser.add_argument('--tn3270e', choices=['off', 'basic', 'default'], metavar='profile', default='default', dest='tn3270e_profile', diff --git a/oec/terminal.py b/oec/terminal.py index 68215cb..6e5c10d 100644 --- a/oec/terminal.py +++ b/oec/terminal.py @@ -50,8 +50,9 @@ def setup(self): self.display.clear(clear_status_line=True) - # Show the attached indicator on the status line. - self.display.status_line.write_string(0, 'OEC') + # Show the attached indicator on the status line, padded to full width. + columns = self.display.status_line.columns + self.display.status_line.write_string(0, 'OEC'.ljust(columns)) self.display.move_cursor(row=0, column=0) diff --git a/oec/tn3270.py b/oec/tn3270.py index 5592828..fcb8318 100644 --- a/oec/tn3270.py +++ b/oec/tn3270.py @@ -48,7 +48,7 @@ class TN3270Session(Session): """TN3270 session.""" - def __init__(self, terminal, host, port, device_names, character_encoding, tn3270e_profile, ssl_enabled=False, no_starttls=False, ssl_no_verify=False): + def __init__(self, terminal, host, port, device_names, character_encoding, tn3270e_profile, ssl_enabled=False, no_starttls=False, ssl_no_verify=False, no_hostname_status=False): super().__init__(terminal) self.logger = logging.getLogger(__name__) @@ -61,6 +61,7 @@ def __init__(self, terminal, host, port, device_names, character_encoding, tn327 self.ssl_enabled = ssl_enabled self.no_starttls = no_starttls self.ssl_no_verify = ssl_no_verify + self.no_hostname_status = no_hostname_status self.telnet = None self.emulator = None @@ -76,6 +77,11 @@ def __init__(self, terminal, host, port, device_names, character_encoding, tn327 def start(self): self._connect_host() + self._write_security_status() + + if not self.no_hostname_status: + self._write_hostname_status() + (rows, columns) = self.terminal.display.dimensions if self.terminal.display.has_eab: @@ -230,6 +236,23 @@ def _connect_host(self): else: self.logger.debug('Unable to negotiate TN3270E mode') + def _write_security_status(self): + is_secure = isinstance(self.telnet.socket, ssl.SSLSocket) + + self.terminal.display.status_line.write_string(17, 'encrypted' if is_secure else 'unencrypted') + + def _write_hostname_status(self): + status_column = 46 + columns = self.terminal.display.status_line.columns + max_length = columns - status_column + + if self.port == 23: + text = self.host + else: + text = f'{self.host}:{self.port}' + + self.terminal.display.status_line.write_string(status_column, text[:max_length].rjust(max_length)) + def _disconnect_host(self): self.telnet.close() diff --git a/tests/test_terminal.py b/tests/test_terminal.py index c517a2f..cecabcc 100644 --- a/tests/test_terminal.py +++ b/tests/test_terminal.py @@ -39,6 +39,7 @@ def setUp(self): self.terminal.display = create_autospec(Display, instance=True) self.terminal.display.status_line = create_autospec(StatusLine, instance=True) + self.terminal.display.status_line.columns = 80 def test(self): self.terminal.setup() From f7cf817c7ecedb7ada9e5b5df6505c0590489cd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hans=20H=C3=BCbner?= Date: Wed, 8 Apr 2026 07:16:46 +0200 Subject: [PATCH 12/12] Add --log-level option and alarm debug logging Add --log-level command line option (debug/info/warning/error) to control logging verbosity. Add debug log messages for alarm queuing and delivery to help diagnose buzzer issues on physical terminals. Co-Authored-By: Claude Opus 4.6 (1M context) --- oec/__main__.py | 9 ++++++++- oec/args.py | 4 ++++ oec/terminal.py | 6 ++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/oec/__main__.py b/oec/__main__.py index c0f834b..f659f43 100644 --- a/oec/__main__.py +++ b/oec/__main__.py @@ -24,7 +24,12 @@ from .keymap_ibm_typewriter import KEYMAP as KEYMAP_IBM_TYPEWRITER from .keymap_ibm_enhanced import KEYMAP as KEYMAP_IBM_ENHANCED -logging.basicConfig(level=logging.INFO) +_LOG_LEVELS = { + 'debug': logging.DEBUG, + 'info': logging.INFO, + 'warning': logging.WARNING, + 'error': logging.ERROR, +} logger = logging.getLogger('oec.main') @@ -97,6 +102,8 @@ def _create_session(args, device): def main(): args = parse_args(sys.argv[1:], IS_VT100_AVAILABLE) + logging.basicConfig(level=_LOG_LEVELS[args.log_level]) + def create_device(interface, device_address, poll_response): return _create_device(args, interface, device_address, poll_response) diff --git a/oec/args.py b/oec/args.py index e9733b1..868ac00 100644 --- a/oec/args.py +++ b/oec/args.py @@ -14,6 +14,10 @@ def parse_args(args, is_vt100_available): parser.add_argument('serial_port', help='serial port') + parser.add_argument('--log-level', choices=['debug', 'info', 'warning', 'error'], + default='info', dest='log_level', + help='logging level (default: info)') + parser.add_argument('--keyboard-language', choices=['us', 'de'], default='us', dest='keyboard_language', help='keyboard national layout (default: us)') diff --git a/oec/terminal.py b/oec/terminal.py index 6e5c10d..8840c44 100644 --- a/oec/terminal.py +++ b/oec/terminal.py @@ -3,10 +3,14 @@ ~~~~~~~~~~~~ """ +import logging + from coax import LoadControlRegister, Feature, PollAction, Control from .device import Device, UnsupportedDeviceError from .display import Dimensions, BufferedDisplay + +logger = logging.getLogger(__name__) from .keyboard import Keyboard MODEL_DIMENSIONS = { @@ -63,6 +67,7 @@ def get_poll_action(self): # Convert a queued alarm or keyboard clicker change to POLL action. if self.alarm: poll_action = PollAction.ALARM + logger.debug('Alarm delivered via POLL') self.alarm = False elif self.keyboard.clicker != self.last_poll_keyboard_clicker: @@ -77,6 +82,7 @@ def get_poll_action(self): def sound_alarm(self): """Queue an alarm on next POLL command.""" + logger.debug('Alarm queued') self.alarm = True def load_control_register(self):