IBM 3179 EAB support

This commit is contained in:
Andrew Kay 2025-12-10 21:17:16 -06:00
parent b5bea24c4d
commit 5c773e8358
7 changed files with 123 additions and 102 deletions

View File

@ -33,6 +33,7 @@ emulation.
Only CUT (Control Unit Terminal) type terminals are supported. I have tested oec with the following terminals: Only CUT (Control Unit Terminal) type terminals are supported. I have tested oec with the following terminals:
* IBM 3179
* IBM 3278-2 * IBM 3278-2
* IBM 3472 * IBM 3472
* IBM 3483-V (InfoWindow II) * IBM 3483-V (InfoWindow II)

View File

@ -2,13 +2,13 @@ import sys
import os import os
import signal import signal
import logging import logging
from coax import open_serial_interface, TerminalType from coax import open_serial_interface, TerminalType, Feature
from .args import parse_args from .args import parse_args
from .interface import InterfaceWrapper from .interface import InterfaceWrapper
from .controller import Controller from .controller import Controller
from .device import get_ids, get_features, get_keyboard_description, UnsupportedDeviceError from .device import get_ids, get_features, UnsupportedDeviceError
from .terminal import Terminal from .terminal import Terminal, get_model, get_keyboard_description
from .tn3270 import TN3270Session from .tn3270 import TN3270Session
# VT100 emulation is not supported on Windows. # VT100 emulation is not supported on Windows.
@ -40,34 +40,35 @@ def _get_keymap(_args, keyboard_description):
return KEYMAP_3278_TYPEWRITER return KEYMAP_3278_TYPEWRITER
def _create_device(args, interface, device_address, _poll_response): def _create_device(args, interface, device_address, _poll_response):
# Read the terminal identifiers.
(terminal_id, extended_id) = get_ids(interface, device_address) (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: if terminal_id.type != TerminalType.CUT:
raise UnsupportedDeviceError('Only CUT type terminals are supported') 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: if model is not None:
logger.info(f'Model = IBM {extended_id[2:6]} or equivalent') 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) keyboard_description = get_keyboard_description(terminal_id, extended_id)
logger.info(f'Keyboard = {keyboard_description}') 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) keymap = _get_keymap(args, keyboard_description)
logger.info(f'Keymap = {keymap.name}') logger.info(f'Keymap = {keymap.name}')
# Create the terminal.
terminal = Terminal(interface, device_address, terminal_id, extended_id, features, keymap) terminal = Terminal(interface, device_address, terminal_id, extended_id, features, keymap)
return terminal return terminal

View File

@ -34,8 +34,8 @@ class Device:
"""Execute one or more commands.""" """Execute one or more commands."""
return self.interface.execute(address_commands(self.device_address, 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): def prepare_jumbo_write(self, data, create_first, create_subsequent, first_chunk_max_length_adjustment=-1):
"""Execute a jumbo write command that can be split.""" """Prepare a jumbo write command that can be split."""
max_length = None max_length = None
# The 3299 multiplexer appears to have some frame length limit, after which it will # 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): if len(commands) > 1 and logger.isEnabledFor(logging.DEBUG):
logger.debug(f'Jumbo write split into {len(commands)}') logger.debug(f'Jumbo write split into {len(commands)}')
return self.execute(commands) return commands
class UnsupportedDeviceError(Exception): class UnsupportedDeviceError(Exception):
"""Unsupported device.""" """Unsupported device."""
@ -111,65 +111,6 @@ def get_features(interface, device_address):
return parse_features(ids, commands) 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): def _jumbo_write_split_data(data, max_length, first_chunk_max_length_adjustment=-1):
if max_length is None: if max_length is None:
return [data] return [data]

View File

@ -175,12 +175,18 @@ class Display:
return True return True
def _write_data(self, data): 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): 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 # The EAB_WRITE_ALTERNATE command data must be split so that the two bytes
# do not get separated, otherwise the write will be incorrect. # 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): def _split_address(address):
if address is None: if address is None:

View File

@ -81,3 +81,75 @@ class Terminal(Device):
def load_control_register(self): def load_control_register(self):
"""Execute a LOAD_CONTROL_REGISTER command.""" """Execute a LOAD_CONTROL_REGISTER command."""
self.execute(LoadControlRegister(self.control)) 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

View File

@ -8,7 +8,7 @@ from coax.protocol import TerminalId
import context import context
from oec.interface import InterfaceWrapper 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 from mock_interface import MockInterface
@ -161,27 +161,6 @@ class GetFeaturesTestCase(unittest.TestCase):
# Assert # Assert
self.assertEqual(features, { Feature.EAB: 7 }) 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): class JumboWriteSplitDataTestCase(unittest.TestCase):
def test_no_split_strategy(self): def test_no_split_strategy(self):
for data in [bytes(range(0, 64)), (bytes.fromhex('00'), 64)]: for data in [bytes(range(0, 64)), (bytes.fromhex('00'), 64)]:

View File

@ -7,7 +7,7 @@ import context
from oec.interface import InterfaceWrapper from oec.interface import InterfaceWrapper
from oec.device import UnsupportedDeviceError 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.display import Display, StatusLine
from oec.keymap_3278_typewriter import KEYMAP from oec.keymap_3278_typewriter import KEYMAP
@ -75,6 +75,27 @@ class TerminalGetPollActionTestCase(unittest.TestCase):
# Act and assert # Act and assert
self.assertEqual(self.terminal.get_poll_action(), PollAction.ENABLE_KEYBOARD_CLICKER) 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): def _create_terminal(interface):
terminal_id = TerminalId(0b11110100) terminal_id = TerminalId(0b11110100)
extended_id = 'c1348300' extended_id = 'c1348300'