From 5c773e83585f03bc0199e6313be92ec7fdf9b719 Mon Sep 17 00:00:00 2001 From: Andrew Kay Date: Wed, 10 Dec 2025 21:17:16 -0600 Subject: [PATCH] IBM 3179 EAB support --- README.md | 1 + oec/__main__.py | 31 +++++++++--------- oec/device.py | 65 ++------------------------------------ oec/display.py | 10 ++++-- oec/terminal.py | 72 ++++++++++++++++++++++++++++++++++++++++++ tests/test_device.py | 23 +------------- tests/test_terminal.py | 23 +++++++++++++- 7 files changed, 123 insertions(+), 102 deletions(-) diff --git a/README.md b/README.md index 67f3a21..880d355 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ emulation. Only CUT (Control Unit Terminal) type terminals are supported. I have tested oec with the following terminals: + * IBM 3179 * IBM 3278-2 * IBM 3472 * IBM 3483-V (InfoWindow II) diff --git a/oec/__main__.py b/oec/__main__.py index 9a03138..2b33519 100644 --- a/oec/__main__.py +++ b/oec/__main__.py @@ -2,13 +2,13 @@ import sys import os import signal import logging -from coax import open_serial_interface, TerminalType +from coax import open_serial_interface, TerminalType, Feature from .args import parse_args from .interface import InterfaceWrapper from .controller import Controller -from .device import get_ids, get_features, get_keyboard_description, UnsupportedDeviceError -from .terminal import Terminal +from .device import get_ids, get_features, UnsupportedDeviceError +from .terminal import Terminal, get_model, get_keyboard_description from .tn3270 import TN3270Session # VT100 emulation is not supported on Windows. @@ -40,34 +40,35 @@ def _get_keymap(_args, keyboard_description): return KEYMAP_3278_TYPEWRITER def _create_device(args, interface, device_address, _poll_response): - # Read the terminal identifiers. (terminal_id, extended_id) = get_ids(interface, device_address) - logger.info(f'Terminal ID = {terminal_id}') + logger.info(f'Terminal ID = {terminal_id}, Extended ID = {extended_id}') if terminal_id.type != TerminalType.CUT: raise UnsupportedDeviceError('Only CUT type terminals are supported') - logger.info(f'Extended ID = {extended_id}') + model = get_model(terminal_id, extended_id) - if extended_id is not None: - logger.info(f'Model = IBM {extended_id[2:6]} or equivalent') + if model is not None: + logger.info(f'Model = IBM {model} or equivalent') + + features = get_features(interface, device_address) + + # The 3179 includes an EAB but does not respond to the READ_FEATURE_ID + # command. + if model == '3179': + features[Feature.EAB] = 7 + + logger.info(f'Features = {features}') keyboard_description = get_keyboard_description(terminal_id, extended_id) logger.info(f'Keyboard = {keyboard_description}') - # Read the terminal features. - features = get_features(interface, device_address) - - logger.info(f'Features = {features}') - - # Get the keymap. keymap = _get_keymap(args, keyboard_description) logger.info(f'Keymap = {keymap.name}') - # Create the terminal. terminal = Terminal(interface, device_address, terminal_id, extended_id, features, keymap) return terminal diff --git a/oec/device.py b/oec/device.py index 47211a4..fda0451 100644 --- a/oec/device.py +++ b/oec/device.py @@ -34,8 +34,8 @@ class Device: """Execute one or more commands.""" return self.interface.execute(address_commands(self.device_address, commands)) - def execute_jumbo_write(self, data, create_first, create_subsequent, first_chunk_max_length_adjustment=-1): - """Execute a jumbo write command that can be split.""" + def prepare_jumbo_write(self, data, create_first, create_subsequent, first_chunk_max_length_adjustment=-1): + """Prepare a jumbo write command that can be split.""" max_length = None # The 3299 multiplexer appears to have some frame length limit, after which it will @@ -55,7 +55,7 @@ class Device: if len(commands) > 1 and logger.isEnabledFor(logging.DEBUG): logger.debug(f'Jumbo write split into {len(commands)}') - return self.execute(commands) + return commands class UnsupportedDeviceError(Exception): """Unsupported device.""" @@ -111,65 +111,6 @@ def get_features(interface, device_address): return parse_features(ids, commands) -def get_keyboard_description(terminal_id, extended_id): - is_3278 = extended_id is None or not int(extended_id[0:2], 16) & 0x80 - - if is_3278: - description = '3278' - - id_map = { - 0b0001: 'APL', - 0b0010: 'TEXT', - 0b0100: 'TYPEWRITER-PSHICO', - 0b0101: 'APL', - 0b0110: 'TEXT', - 0b0111: 'APL-PSHICO', - 0b1000: 'DATAENTRY-2', - 0b1001: 'DATAENTRY-1', - 0b1010: 'TYPEWRITER', - 0b1100: 'DATAENTRY-2', - 0b1101: 'DATAENTRY-1', - 0b1110: 'TYPEWRITER' - } - - if terminal_id.keyboard in id_map: - description += '-' + id_map[terminal_id.keyboard] - - return description - - id_ = int(extended_id[0:2], 16) & 0x1f - - is_user = int(extended_id[0:2], 16) & 0x20 - - if is_user: - description = 'USER' - - if id_ in [1, 2, 3, 4]: - description += f'-{id_}' - - return description - - is_ibm = not int(extended_id[6:8], 16) & 0x80 - - description = 'IBM' if is_ibm else 'UNKNOWN' - - is_enhanced = int(extended_id[6:8], 16) & 0x01 - - if is_enhanced: - if id_ == 1: - return description + '-ENHANCED' - - return None - - if id_ == 1: - return description + '-TYPEWRITER' - elif id_ == 2: - return description + '-DATAENTRY' - elif id_ == 3: - return description + '-APL' - - return None - def _jumbo_write_split_data(data, max_length, first_chunk_max_length_adjustment=-1): if max_length is None: return [data] diff --git a/oec/display.py b/oec/display.py index 991e454..aa6e49d 100644 --- a/oec/display.py +++ b/oec/display.py @@ -175,12 +175,18 @@ class Display: return True def _write_data(self, data): - self.terminal.execute_jumbo_write(data, WriteData, Data, -1) + self.terminal.execute(self.terminal.prepare_jumbo_write(data, WriteData, Data, -1)) def _eab_write_alternate(self, data): + # The EAB mask on a 3179 terminal appears to get reset regularly resulting + # in the EAB buffer not being updated correctly. This does not affect + # later terminals, loading the mask here for all terminals is simpler. + # # The EAB_WRITE_ALTERNATE command data must be split so that the two bytes # do not get separated, otherwise the write will be incorrect. - self.terminal.execute_jumbo_write(data, lambda chunk: EABWriteAlternate(self.eab_address, chunk), Data, -2) + commands = [EABLoadMask(self.eab_address, 0xff), *self.terminal.prepare_jumbo_write(data, lambda chunk: EABWriteAlternate(self.eab_address, chunk), Data, -2)] + + self.terminal.execute(commands) def _split_address(address): if address is None: diff --git a/oec/terminal.py b/oec/terminal.py index b9c540a..68215cb 100644 --- a/oec/terminal.py +++ b/oec/terminal.py @@ -81,3 +81,75 @@ class Terminal(Device): def load_control_register(self): """Execute a LOAD_CONTROL_REGISTER command.""" self.execute(LoadControlRegister(self.control)) + +def get_model(terminal_id, extended_id): + if extended_id is None: + return None + + model = extended_id[2:6] + + # The 3179 does return an extended ID, but it does not include the model + # like later terminals. + if model == '0000': + model = '3179' + + return model + +def get_keyboard_description(terminal_id, extended_id): + is_3278 = extended_id is None or not int(extended_id[0:2], 16) & 0x80 + + if is_3278: + description = '3278' + + id_map = { + 0b0001: 'APL', + 0b0010: 'TEXT', + 0b0100: 'TYPEWRITER-PSHICO', + 0b0101: 'APL', + 0b0110: 'TEXT', + 0b0111: 'APL-PSHICO', + 0b1000: 'DATAENTRY-2', + 0b1001: 'DATAENTRY-1', + 0b1010: 'TYPEWRITER', + 0b1100: 'DATAENTRY-2', + 0b1101: 'DATAENTRY-1', + 0b1110: 'TYPEWRITER' + } + + if terminal_id.keyboard in id_map: + description += '-' + id_map[terminal_id.keyboard] + + return description + + id_ = int(extended_id[0:2], 16) & 0x1f + + is_user = int(extended_id[0:2], 16) & 0x20 + + if is_user: + description = 'USER' + + if id_ in [1, 2, 3, 4]: + description += f'-{id_}' + + return description + + is_ibm = not int(extended_id[6:8], 16) & 0x80 + + description = 'IBM' if is_ibm else 'UNKNOWN' + + is_enhanced = int(extended_id[6:8], 16) & 0x01 + + if is_enhanced: + if id_ == 1: + return description + '-ENHANCED' + + return None + + if id_ == 1: + return description + '-TYPEWRITER' + elif id_ == 2: + return description + '-DATAENTRY' + elif id_ == 3: + return description + '-APL' + + return None diff --git a/tests/test_device.py b/tests/test_device.py index 692fa5a..5cd3bf2 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -8,7 +8,7 @@ from coax.protocol import TerminalId import context from oec.interface import InterfaceWrapper -from oec.device import address_commands, format_address, get_ids, get_features, get_keyboard_description, _jumbo_write_split_data +from oec.device import address_commands, format_address, get_ids, get_features, _jumbo_write_split_data from mock_interface import MockInterface @@ -161,27 +161,6 @@ class GetFeaturesTestCase(unittest.TestCase): # Assert self.assertEqual(features, { Feature.EAB: 7 }) -class GetKeyboardDescriptionTestCase(unittest.TestCase): - def test(self): - CASES = [ - (10, None, '3278-TYPEWRITER'), - (0, 'c1347200', 'IBM-TYPEWRITER'), - (10, '41347200', '3278-TYPEWRITER'), - (0, 'c2347200', 'IBM-DATAENTRY'), - (0, 'c3347200', 'IBM-APL'), - (0, 'c1348301', 'IBM-ENHANCED'), - (0, 'e1347200', 'USER-1'), - (0, 'e4347200', 'USER-4') - ] - - for (keyboard, extended_id, expected_description) in CASES: - with self.subTest(keyboard=keyboard, extended_id=extended_id): - terminal_id = TerminalId(0b0000_0100 | (keyboard << 4)) - - description = get_keyboard_description(terminal_id, extended_id) - - self.assertEqual(description, expected_description) - class JumboWriteSplitDataTestCase(unittest.TestCase): def test_no_split_strategy(self): for data in [bytes(range(0, 64)), (bytes.fromhex('00'), 64)]: diff --git a/tests/test_terminal.py b/tests/test_terminal.py index 03decb0..c517a2f 100644 --- a/tests/test_terminal.py +++ b/tests/test_terminal.py @@ -7,7 +7,7 @@ import context from oec.interface import InterfaceWrapper from oec.device import UnsupportedDeviceError -from oec.terminal import Terminal +from oec.terminal import Terminal, get_keyboard_description from oec.display import Display, StatusLine from oec.keymap_3278_typewriter import KEYMAP @@ -75,6 +75,27 @@ class TerminalGetPollActionTestCase(unittest.TestCase): # Act and assert self.assertEqual(self.terminal.get_poll_action(), PollAction.ENABLE_KEYBOARD_CLICKER) +class GetKeyboardDescriptionTestCase(unittest.TestCase): + def test(self): + CASES = [ + (10, None, '3278-TYPEWRITER'), + (0, 'c1347200', 'IBM-TYPEWRITER'), + (10, '41347200', '3278-TYPEWRITER'), + (0, 'c2347200', 'IBM-DATAENTRY'), + (0, 'c3347200', 'IBM-APL'), + (0, 'c1348301', 'IBM-ENHANCED'), + (0, 'e1347200', 'USER-1'), + (0, 'e4347200', 'USER-4') + ] + + for (keyboard, extended_id, expected_description) in CASES: + with self.subTest(keyboard=keyboard, extended_id=extended_id): + terminal_id = TerminalId(0b0000_0100 | (keyboard << 4)) + + description = get_keyboard_description(terminal_id, extended_id) + + self.assertEqual(description, expected_description) + def _create_terminal(interface): terminal_id = TerminalId(0b11110100) extended_id = 'c1348300'