Initial commit

This commit is contained in:
Andrew Kay 2019-06-15 22:03:26 -05:00
commit 7c2969307b
16 changed files with 1562 additions and 0 deletions

112
.gitignore vendored Normal file
View File

@ -0,0 +1,112 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
VIRTUALENV/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json

13
LICENSE Normal file
View File

@ -0,0 +1,13 @@
Copyright (c) 2019, Andrew Kay
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

27
README.md Normal file
View File

@ -0,0 +1,27 @@
# oec
oec is an open replacement for the IBM 3174 Establishment Controller.
It is still a work in progress - as of now it only provides basic VT100 emulation but the goal is to implement TN3270 and multiple logical terminal support.
## Usage
You will need to build a [interface](https://github.com/lowobservable/coax-interface) and connect it to your computer.
Then configure a Python virtual environment and install dependencies:
```
python -m venv VIRTUALENV
. VIRTUALENV/bin/activate
pip install -r requirements.txt --no-deps
```
Assuming your interface is connected to `/dev/ttyUSB0` and you want to run `/bin/sh` as the host process:
```
python -m oec /dev/ttyUSB0 /bin/sh -l
```
## See Also
* [coax-interface](https://github.com/lowobservable/coax-interface) - tools for interfacing with IBM 3270 type terminals

42
oec/__main__.py Normal file
View File

@ -0,0 +1,42 @@
import time
import logging
import argparse
from serial import Serial
from coax import Interface1
from .controller import Controller
logging.basicConfig(level=logging.INFO)
def main():
parser = argparse.ArgumentParser(description='VT100 emulator.')
parser.add_argument('port', help='Serial port')
parser.add_argument('command', help='Host process')
parser.add_argument('command_args', nargs=argparse.REMAINDER, help='Host process arguments')
args = parser.parse_args()
with Serial(args.port, 115200) as serial:
serial.reset_input_buffer()
serial.reset_output_buffer()
# Allow the interface firmware time to start.
time.sleep(3)
# Initialize the interface.
interface = Interface1(serial)
firmware_version = interface.reset()
print(f'Interface firmware version {firmware_version}')
# Initialize and start the controller.
controller = Controller(interface, [args.command, *args.command_args])
print('Starting controller...')
controller.run()
if __name__ == '__main__':
main()

192
oec/controller.py Normal file
View File

@ -0,0 +1,192 @@
"""
oec.controller
~~~~~~~~~~~~~~
"""
import time
import os
from select import select
import logging
from ptyprocess import PtyProcess
from coax import poll, poll_ack, read_terminal_id, read_extended_id, \
KeystrokePollResponse, ReceiveTimeout, ReceiveError, \
ProtocolError
from .terminal import Terminal
from .emulator import VT100Emulator
class Controller:
"""The controller."""
def __init__(self, interface, host_command):
self.logger = logging.getLogger(__name__)
self.running = True
self.interface = interface
self.host_command = host_command
self.terminal = None
self.host_process = None
self.emulator = None
def run(self):
"""Run the controller."""
while self.running:
if self.host_process:
try:
if self.host_process in select([self.host_process], [], [], 0)[0]:
data = self.host_process.read()
self._handle_host_process_output(data)
except EOFError:
self._handle_host_process_terminated()
try:
poll_response = poll(self.interface, timeout=1)
except ReceiveTimeout:
if self.terminal:
self._handle_terminal_detached()
continue
except ReceiveError as error:
self.logger.warning(f'POLL receive error: {error}', exc_info=error)
continue
except ProtocolError as error:
self.logger.warning(f'POLL protocol error: {error}', exc_info=error)
continue
if poll_response:
try:
poll_ack(self.interface)
except ReceiveError as error:
self.logger.warning(f'POLL_ACK receive error: {error}', exc_info=error)
except ProtocolError as error:
self.logger.warning(f'POLL_ACK protocol error: {error}', exc_info=error)
if not self.terminal:
self._handle_terminal_attached(poll_response)
if poll_response:
self._handle_poll_response(poll_response)
else:
time.sleep(0.1)
def _handle_terminal_attached(self, poll_response):
self.logger.info('Terminal attached')
# Read the terminal identifiers.
(terminal_id, extended_id) = self._read_terminal_ids()
self.logger.info(f'Terminal ID = {terminal_id}, Extended ID = {extended_id}')
# Initialize the terminal.
self.terminal = Terminal(self.interface, terminal_id, extended_id)
(rows, columns) = self.terminal.dimensions
keymap_name = self.terminal.keyboard.keymap.name
self.logger.info(f'Rows = {rows}, Columns = {columns}, Keymap = {keymap_name}')
self.terminal.clear_screen()
# Show the attached indicator on the status line.
self.terminal.status_line.write_string(0, 'S')
# Start the process.
self.host_process = self._start_host_process()
# Initialize the emulator.
self.emulator = VT100Emulator(self.terminal, self.host_process)
def _read_terminal_ids(self):
terminal_id = None
extended_id = None
try:
terminal_id = read_terminal_id(self.interface)
except ReceiveError as error:
self.logger.warning(f'READ_TERMINAL_ID receive error: {error}', exc_info=error)
except ProtocolError as error:
self.logger.warning(f'READ_TERMINAL_ID protocol error: {error}', exc_info=error)
try:
extended_id = read_extended_id(self.interface)
except ReceiveError as error:
self.logger.warning(f'READ_EXTENDED_ID receive error: {error}', exc_info=error)
except ProtocolError as error:
self.logger.warning(f'READ_EXTENDED_ID protocol error: {error}', exc_info=error)
return (terminal_id, extended_id.hex() if extended_id is not None else None)
def _handle_terminal_detached(self):
self.logger.info('Terminal detached')
if self.host_process:
self.logger.debug('Terminating host process')
if not self.host_process.terminate(force=True):
self.logger.error('Unable to terminate host process')
else:
self.logger.debug('Host process terminated')
self.terminal = None
self.host_process = None
self.emulator = None
def _handle_poll_response(self, poll_response):
if isinstance(poll_response, KeystrokePollResponse):
self._handle_keystroke_poll_response(poll_response)
def _handle_keystroke_poll_response(self, poll_response):
scan_code = poll_response.scan_code
(key, modifiers, modifiers_changed) = self.terminal.keyboard.get_key(scan_code)
# Update the status line if modifiers have changed.
if modifiers_changed:
indicators = bytearray(1)
if modifiers.is_shift():
indicators[0] = 0xda
else:
indicators[0] = 0x00
self.terminal.status_line.write(35, indicators)
if self.logger.isEnabledFor(logging.DEBUG):
self.logger.debug((f'Keystroke detected: Scan Code = {scan_code}, '
f'Key = {key}, Modifiers = {modifiers}'))
if not key:
return
if self.emulator:
self.emulator.handle_key(key, modifiers, scan_code)
def _start_host_process(self):
environment = os.environ.copy()
environment['TERM'] = 'vt100'
environment['LC_ALL'] = 'C'
process = PtyProcess.spawn(self.host_command, env=environment,
dimensions=self.terminal.dimensions)
return process
def _handle_host_process_output(self, data):
if self.logger.isEnabledFor(logging.DEBUG):
self.logger.debug(f'Output from host process: {data}')
if self.emulator:
self.emulator.handle_host_output(data)
def _handle_host_process_terminated(self):
self.logger.info('Host process terminated')
if self.host_process.isalive():
self.logger.error('Host process is reporting as alive')
self.host_process = None
self.emulator = None

161
oec/display.py Normal file
View File

@ -0,0 +1,161 @@
"""
oec.display
~~~~~~~~~~~
"""
_ASCII_CHAR_MAP = {
'>': 0x08,
'<': 0x09,
'[': 0x0a,
']': 0x0b,
')': 0x0c,
'(': 0x0d,
'}': 0x0e,
'{': 0x0f,
# 0x10 - A real space?
'=': 0x11,
'\'': 0x12,
'"': 0x13,
'/': 0x14,
'\\': 0x15,
'|': 0x16,
'¦': 0x17,
'?': 0x18,
'!': 0x19,
'$': 0x1a,
'¢': 0x1b,
'£': 0x1c,
'¥': 0x1d,
# 0x1e - A P/T looking symbol
# 0x1f - A intertwined parens symbol
'0': 0x20,
'1': 0x21,
'2': 0x22,
'3': 0x23,
'4': 0x24,
'5': 0x25,
'6': 0x26,
'7': 0x27,
'8': 0x28,
'9': 0x29,
'ß': 0x2a,
'§': 0x2b,
'#': 0x2c,
'@': 0x2d,
'%': 0x2e,
'_': 0x2f,
'&': 0x30,
'-': 0x31,
'.': 0x32,
',': 0x33,
':': 0x34,
'+': 0x35,
'¬': 0x36,
'¯': 0x37, # ???
'°': 0x38,
# 0x39 - Accent?
# 0x3a - Accent?
# 0x3b - A tilde? It looks more like an accent...
'¨': 0x3c,
# 0x3d - Accute accent?
# 0x3e - Opposite of accute accent?
# 0x3f - A more extreme comma?
'a': 0x80,
'b': 0x81,
'c': 0x82,
'd': 0x83,
'e': 0x84,
'f': 0x85,
'g': 0x86,
'h': 0x87,
'i': 0x88,
'j': 0x89,
'k': 0x8a,
'l': 0x8b,
'm': 0x8c,
'n': 0x8d,
'o': 0x8e,
'p': 0x8f,
'q': 0x90,
'r': 0x91,
's': 0x92,
't': 0x93,
'u': 0x94,
'v': 0x95,
'w': 0x96,
'x': 0x97,
'y': 0x98,
'z': 0x99,
'æ': 0x9a,
'ø': 0x9b,
'å': 0x9c,
'ç': 0x9d,
# 0x9e - Semi colon with top line
# 0x9f - Asterisk with top line
'A': 0xa0,
'B': 0xa1,
'C': 0xa2,
'D': 0xa3,
'E': 0xa4,
'F': 0xa5,
'G': 0xa6,
'H': 0xa7,
'I': 0xa8,
'J': 0xa9,
'K': 0xaa,
'L': 0xab,
'M': 0xac,
'N': 0xad,
'O': 0xae,
'P': 0xaf,
'Q': 0xb0,
'R': 0xb1,
'S': 0xb2,
'T': 0xb3,
'U': 0xb4,
'V': 0xb5,
'W': 0xb6,
'X': 0xb7,
'Y': 0xb8,
'Z': 0xb9,
'Æ': 0xba,
'Ø': 0xbb,
'Å': 0xbc,
'Ç': 0xbd,
';': 0xbe,
'*': 0xbf
}
ASCII_CHAR_MAP = [_ASCII_CHAR_MAP.get(character, 0x00) for character in map(chr, range(256))]
def encode_ascii_character(character):
"""Map an ASCII character to a terminal display character."""
if character > 255:
return 0x00
return ASCII_CHAR_MAP[character]
def encode_string(string, errors='replace'):
"""Map a string to terminal display characters."""
return bytes([encode_ascii_character(character) for character
in string.encode('ascii', errors)])
# TODO: remove default columns
# TODO: add validation of column and data length for write() - must be inside status line
class StatusLine:
def __init__(self, interface, columns=80):
self.interface = interface
self.columns = columns
def write(self, column, data):
self.interface.offload_write(data, address=column, restore_original_address=True)
def write_string(self, column, string):
self.write(column, encode_string(string))

250
oec/emulator.py Normal file
View File

@ -0,0 +1,250 @@
"""
oec.emulator
~~~~~~~~~~~~
"""
import logging
import pyte
from coax import write_data
from .display import encode_ascii_character
from .keyboard import Key, get_ascii_character_for_key
VT100_KEY_MAP = {
Key.NOT: b'^',
Key.CENT: b'[',
Key.BROKEN_BAR: b']',
Key.ATTN: b'\x1b', # Escape
Key.NEWLINE: b'\r',
Key.ENTER: b'\r',
Key.FIELD_EXIT: b'\r',
Key.BACKSPACE: b'\b',
Key.TAB: b'\t',
Key.UP: b'\x1b[A',
Key.DOWN: b'\x1b[B',
Key.LEFT: b'\x1b[D',
Key.RIGHT: b'\x1b[C'
}
VT100_KEY_MAP_ALT = {
Key.SPACE: b'\x00',
Key.LOWER_A: b'\x01',
Key.LOWER_B: b'\x02',
Key.LOWER_C: b'\x03',
Key.LOWER_D: b'\x04',
Key.LOWER_E: b'\x05',
Key.LOWER_F: b'\x06',
Key.LOWER_G: b'\x07',
Key.LOWER_H: b'\x08',
Key.LOWER_I: b'\x09',
Key.LOWER_J: b'\x0a',
Key.LOWER_K: b'\x0b',
Key.LOWER_L: b'\x0c',
Key.LOWER_M: b'\x0d',
Key.LOWER_N: b'\x0e',
Key.LOWER_O: b'\x0f',
Key.LOWER_P: b'\x10',
Key.LOWER_Q: b'\x11',
Key.LOWER_R: b'\x12',
Key.LOWER_S: b'\x13',
Key.LOWER_T: b'\x14',
Key.LOWER_U: b'\x15',
Key.LOWER_V: b'\x16',
Key.LOWER_W: b'\x17',
Key.LOWER_X: b'\x18',
Key.LOWER_Y: b'\x19',
Key.LOWER_Z: b'\x1a',
Key.CENT: b'\x1b', # Ctrl + [
Key.BACKSLASH: b'\x1c',
Key.EQUAL: b'\x1d', # Ctrl + ]
Key.LESS: b'\x1e', # Ctrl + ~
Key.SLASH: b'\x1f', # Ctrl + ?
Key.NEWLINE: b'\n'
}
class VT100Emulator:
"""VT100 emulator."""
def __init__(self, terminal, host):
self.logger = logging.getLogger(__name__)
self.terminal = terminal
self.host = host
self.rows = self.terminal.dimensions.rows
self.columns = self.terminal.dimensions.columns
self.vt100_screen = pyte.Screen(self.columns, self.rows)
self.vt100_screen.write_process_input = lambda data: host.write(data.encode())
self.vt100_stream = pyte.ByteStream(self.vt100_screen)
# TODO: Consider moving the following three attributes to the Terminal class
# and moving the associated methods.
self.buffer = bytearray(self.rows * self.columns)
self.dirty = [False for index in range(self.rows * self.columns)]
self.address_counter = self._calculate_address(0)
# Clear the screen.
self.terminal.clear_screen()
# Update the status line.
self.terminal.status_line.write_string(45, 'VT100')
# Load the address counter.
self.terminal.interface.offload_load_address_counter(self.address_counter)
def handle_key(self, key, keyboard_modifiers, scan_code):
"""Handle a terminal keystroke."""
bytes_ = self._map_key(key, keyboard_modifiers)
if bytes_ is not None:
self.host.write(bytes_)
def handle_host_output(self, data):
"""Handle output from the host process."""
self.vt100_stream.feed(data)
self.update()
def update(self):
"""Update the terminal with dirty changes from the VT100 screen - clears
dirty lines after updating terminal.
"""
self._apply(self.vt100_screen)
self.vt100_screen.dirty.clear()
self._flush()
def _map_key(self, key, keyboard_modifiers):
if keyboard_modifiers.is_alt():
bytes_ = VT100_KEY_MAP_ALT.get(key)
if bytes_ is not None:
return bytes_
self.logger.warning(f'No key mapping found for ALT + {key}')
else:
bytes_ = VT100_KEY_MAP.get(key)
if bytes_ is not None:
return bytes_
character = get_ascii_character_for_key(key)
if character and character.isprintable():
return character.encode()
return None
def _get_index(self, row, column):
return (row * self.columns) + column
def _calculate_address(self, cursor_index):
return self.columns + cursor_index
def _apply(self, screen):
for row in screen.dirty:
row_buffer = screen.buffer[row]
for column in range(self.columns):
character = row_buffer[column]
# TODO: Investigate multi-byte or zero-byte cases further.
# TODO: Add additional mapping for special cases such as '^'...
byte = encode_ascii_character(ord(character.data)) if len(character.data) == 1 else 0x00
index = self._get_index(row, column)
if self.buffer[index] != byte:
self.buffer[index] = byte
self.dirty[index] = True
def _flush(self):
for (start_index, end_index) in self._get_dirty_ranges():
self._flush_range(start_index, end_index)
# Syncronize the cursor.
cursor = self.vt100_screen.cursor
address = self._calculate_address(self._get_index(cursor.y, cursor.x))
# TODO: Investigate different approaches to reducing the need to syncronize the cursor
# or make it more reliable.
if address != self.address_counter:
if self.logger.isEnabledFor(logging.DEBUG):
self.logger.debug((f'Setting address counter: Address = {address}, '
f'Address Counter = {self.address_counter}'))
self.terminal.interface.offload_load_address_counter(address)
self.address_counter = address
else:
if self.logger.isEnabledFor(logging.DEBUG):
self.logger.debug((f'Skipping address counter: Address Counter = '
f'{self.address_counter}'))
def _get_dirty_ranges(self):
ranges = []
start_index = 0
while start_index < len(self.dirty):
if self.dirty[start_index]:
break
start_index += 1
end_index = len(self.dirty) - 1
while end_index >= 0:
if self.dirty[end_index]:
break
end_index -= 1
if start_index < len(self.dirty) and end_index >= 0:
ranges.append((start_index, end_index))
return ranges
def _flush_range(self, start_index, end_index):
if self.logger.isEnabledFor(logging.DEBUG):
self.logger.debug(f'Flushing changes for range {start_index}-{end_index}')
data = self.buffer[start_index:end_index+1]
address = self._calculate_address(start_index)
# TODO: Consider using offload for all writing - set address to None if it is the
# same as the current address counter to avoid the additional load command.
if address != self.address_counter:
try:
self.terminal.interface.offload_write(data, address=address)
except Exception as error:
self.logger.error(f'Offload write error: {error}', exc_info=error)
self.address_counter = address + len(data)
else:
try:
write_data(self.terminal.interface, data)
except Exception as error:
self.logger.error(f'WRITE_DATA error: {error}', exc_info=error)
self.address_counter += len(data)
# Force the address counter to be updated...
if self.address_counter >= self._calculate_address((self.rows * self.columns) - 1):
self.address_counter = None
for index in range(start_index, end_index+1):
self.dirty[index] = False
return self.address_counter

360
oec/keyboard.py Normal file
View File

@ -0,0 +1,360 @@
"""
oec.keyboard
~~~~~~~~~~~~
"""
from enum import IntEnum, IntFlag, unique, auto
from collections import namedtuple
class KeyboardModifiers(IntFlag):
"""Keyboard modifiers."""
LEFT_SHIFT = auto()
RIGHT_SHIFT = auto()
LEFT_ALT = auto()
RIGHT_ALT = auto()
CAPS_LOCK = auto()
NONE = 0
def is_shift(self):
"""Is either SHIFT key pressed?"""
return bool(self & (KeyboardModifiers.LEFT_SHIFT | KeyboardModifiers.RIGHT_SHIFT))
def is_alt(self):
"""Is either ALT key pressed?"""
return bool(self & (KeyboardModifiers.LEFT_ALT | KeyboardModifiers.RIGHT_ALT))
def is_caps_lock(self):
"""Is CAPS LOCK toggled on?"""
return bool(self & KeyboardModifiers.CAPS_LOCK)
@unique
class Key(IntEnum):
"""Keyboad key."""
# Modifiers
LEFT_SHIFT = 256
RIGHT_SHIFT = 257
LEFT_ALT = 258
RIGHT_ALT = 259
CAPS_LOCK = 260
# Cursor Movement
SPACE = ord(' ')
BACKSPACE = 261
TAB = ord('\t')
BACKTAB = 262
NEWLINE = 263
INSERT = 264
DELETE = 265
LEFT = 266
UP = 267
RIGHT = 268
DOWN = 269
ROLL_UP = 270
ROLL_DOWN = 271
HOME = 272
DUP = 273
BLANK_4 = 274
JUMP = 275 # Alt + BLANK_4
SWAP = 276 # Alt + BACKTAB
# Function
PF1 = 277
PF2 = 278
PF3 = 279
PF4 = 280
PF5 = 281
PF6 = 282
PF7 = 283
PF8 = 284
PF9 = 285
PF10 = 286
PF11 = 287
PF12 = 288
PF13 = 289
PF14 = 290
PF15 = 291
PF16 = 292
PF17 = 293
PF18 = 294
PF19 = 295
PF20 = 296
PF21 = 297
PF22 = 298
PF23 = 299
PF24 = 300
# Control
ENTER = 301
FIELD_EXIT = 302
RESET = 303
QUIT = 304
SYS_RQ = 305
ATTN = 306
BLANK_1 = 307
CLEAR = 308 # Alt + BLANK_1
BLANK_2 = 309
ERASE_INPUT = 310
PRINT = 311
HELP = 312
HEX = 313 # Alt + HELP
BLANK_3 = 314
PLAY = 315
TEST = 316 # Alt + PLAY
SET_UP = 317
RECORD = 318
PAUSE = 319 # Alt + RECORD
FIELD_MARK = 401
CURSOR_SELECT = 402
CURSOR_BLINK = 403
ERASE_EOF = 404
VOLUME = 405
ALT_CURSOR = 406
IDENT = 407
PA1 = 408
PA2 = 409
# Number Pad
NUMPAD_BLANK_1 = 320
NUMPAD_BLANK_2 = 321
NUMPAD_BLANK_3 = 322
NUMPAD_BLANK_4 = 323
NUMPAD_SEVEN = 324
NUMPAD_EIGHT = 325
NUMPAD_NINE = 326
NUMPAD_FIELD_MINUS = 327
NUMPAD_FOUR = 328
NUMPAD_FIVE = 329
NUMPAD_SIX = 330
NUMPAD_BLANK_5 = 331
NUMPAD_ONE = 332
NUMPAD_TWO = 333
NUMPAD_THREE = 334
NUMPAD_FIELD_PLUS = 335
NUMPAD_ZERO = 336
NUMPAD_PERIOD = 337
# Latin
BACKTICK = ord('`')
TILDE = ord('~')
ONE = ord('1')
BAR = ord('|')
TWO = ord('2')
AT = ord('@')
THREE = ord('3')
HASH = ord('#')
FOUR = ord('4')
DOLLAR = ord('$')
FIVE = ord('5')
PERCENT = ord('%')
SIX = ord('6')
NOT = ord('¬')
SEVEN = ord('7')
AMPERSAND = ord('&')
EIGHT = ord('8')
ASTERISK = ord('*')
NINE = ord('9')
LEFT_PAREN = ord('(')
ZERO = ord('0')
RIGHT_PAREN = ord(')')
MINUS = ord('-')
UNDERSCORE = ord('_')
EQUAL = ord('=')
PLUS = ord('+')
LOWER_Q = ord('q')
UPPER_Q = ord('Q')
LOWER_W = ord('w')
UPPER_W = ord('W')
LOWER_E = ord('e')
UPPER_E = ord('E')
LOWER_R = ord('r')
UPPER_R = ord('R')
LOWER_T = ord('t')
UPPER_T = ord('T')
LOWER_Y = ord('y')
UPPER_Y = ord('Y')
LOWER_U = ord('u')
UPPER_U = ord('U')
LOWER_I = ord('i')
UPPER_I = ord('I')
LOWER_O = ord('o')
UPPER_O = ord('O')
LOWER_P = ord('p')
UPPER_P = ord('P')
CENT = ord('¢')
EXCLAMATION = ord('!')
BACKSLASH = ord('\\')
BROKEN_BAR = ord('¦')
LOWER_A = ord('a')
UPPER_A = ord('A')
LOWER_S = ord('s')
UPPER_S = ord('S')
LOWER_D = ord('d')
UPPER_D = ord('D')
LOWER_F = ord('f')
UPPER_F = ord('F')
LOWER_G = ord('g')
UPPER_G = ord('G')
LOWER_H = ord('h')
UPPER_H = ord('H')
LOWER_J = ord('j')
UPPER_J = ord('J')
LOWER_K = ord('k')
UPPER_K = ord('K')
LOWER_L = ord('l')
UPPER_L = ord('L')
SEMICOLON = ord(';')
COLON = ord(':')
SINGLE_QUOTE = ord('\'')
DOUBLE_QUOTE = ord('"')
LEFT_BRACE = ord('{')
RIGHT_BRACE = ord('}')
LESS = ord('<')
GREATER = ord('>')
LOWER_Z = ord('z')
UPPER_Z = ord('Z')
LOWER_X = ord('x')
UPPER_X = ord('X')
LOWER_C = ord('c')
UPPER_C = ord('C')
LOWER_V = ord('v')
UPPER_V = ord('V')
LOWER_B = ord('b')
UPPER_B = ord('B')
LOWER_N = ord('n')
UPPER_N = ord('N')
LOWER_M = ord('m')
UPPER_M = ord('M')
COMMA = ord(',')
# APOSTOPHE?
PERIOD = ord('.')
CENTER_PERIOD = ord('·')
SLASH = ord('/')
QUESTION = ord('?')
KEY_UPPER_MAP = {
Key.LOWER_A: Key.UPPER_A,
Key.LOWER_B: Key.UPPER_B,
Key.LOWER_C: Key.UPPER_C,
Key.LOWER_D: Key.UPPER_D,
Key.LOWER_E: Key.UPPER_E,
Key.LOWER_F: Key.UPPER_F,
Key.LOWER_G: Key.UPPER_G,
Key.LOWER_H: Key.UPPER_H,
Key.LOWER_I: Key.UPPER_I,
Key.LOWER_J: Key.UPPER_J,
Key.LOWER_K: Key.UPPER_K,
Key.LOWER_L: Key.UPPER_L,
Key.LOWER_M: Key.UPPER_M,
Key.LOWER_N: Key.UPPER_N,
Key.LOWER_O: Key.UPPER_O,
Key.LOWER_P: Key.UPPER_P,
Key.LOWER_Q: Key.UPPER_Q,
Key.LOWER_R: Key.UPPER_R,
Key.LOWER_S: Key.UPPER_S,
Key.LOWER_T: Key.UPPER_T,
Key.LOWER_U: Key.UPPER_U,
Key.LOWER_V: Key.UPPER_V,
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_MAP = {upper_key: lower_key for lower_key, upper_key in KEY_UPPER_MAP.items()}
KEY_MODIFIER_MAP = {
Key.LEFT_SHIFT: KeyboardModifiers.LEFT_SHIFT,
Key.RIGHT_SHIFT: KeyboardModifiers.RIGHT_SHIFT,
Key.LEFT_ALT: KeyboardModifiers.LEFT_ALT,
Key.RIGHT_ALT: KeyboardModifiers.RIGHT_ALT,
Key.CAPS_LOCK: KeyboardModifiers.CAPS_LOCK
}
Keymap = namedtuple('Keymap', ['name', 'default', 'shift', 'alt', 'modifier_release'])
class Keyboard:
"""Keyboard state and key mapping."""
def __init__(self, keymap):
if keymap is None:
raise ValueError('Keymap is required')
self.keymap = keymap
self.modifiers = KeyboardModifiers.NONE
def get_key(self, scan_code):
"""Map a scan code to key and update modifiers state."""
key = self.keymap.default.get(scan_code)
if self._apply_modifiers(scan_code, key):
return (key, self.modifiers, True)
if self.modifiers.is_shift():
key = self.keymap.shift.get(scan_code)
elif self.modifiers.is_alt():
key = self.keymap.alt.get(scan_code)
if key is None:
return (None, self.modifiers, False)
if self.modifiers.is_caps_lock():
if not self.modifiers.is_shift():
key = KEY_UPPER_MAP.get(key, key)
else:
key = KEY_LOWER_MAP.get(key, key)
return (key, self.modifiers, False)
def _apply_modifiers(self, scan_code, key):
if scan_code in self.keymap.modifier_release:
released_key = self.keymap.modifier_release[scan_code]
modifier = KEY_MODIFIER_MAP.get(released_key)
if modifier is None:
return False
# Ignore the release of the caps lock key as it acts as a toggle.
if modifier.is_caps_lock():
return False
self.modifiers &= ~modifier
return True
if key in KEY_MODIFIER_MAP:
modifier = KEY_MODIFIER_MAP[key]
if modifier.is_caps_lock():
self.modifiers ^= KeyboardModifiers.CAPS_LOCK
else:
self.modifiers |= modifier
return True
return False
def get_ascii_character_for_key(key):
"""Map a key to ASCII character."""
if not key:
return None
value = int(key)
if value > 255:
return None
return chr(value)

198
oec/keymap_3278_2.py Normal file
View File

@ -0,0 +1,198 @@
"""
oec.keymap_3278_2
~~~~~~~~~~~~~~~~~
"""
from .keyboard import Key, Keymap
KEYMAP_DEFAULT = {
# Control Keys
80: Key.ATTN,
81: Key.CURSOR_SELECT,
82: Key.BLANK_1,
83: Key.BLANK_2,
84: Key.CURSOR_BLINK,
85: Key.ERASE_EOF,
86: Key.PRINT,
87: Key.VOLUME,
# First Row
61: Key.BACKTICK,
33: Key.ONE,
34: Key.TWO,
35: Key.THREE,
36: Key.FOUR,
37: Key.FIVE,
38: Key.SIX,
39: Key.SEVEN,
40: Key.EIGHT,
41: Key.NINE,
32: Key.ZERO,
48: Key.MINUS,
17: Key.EQUAL,
49: Key.BACKSPACE,
# Second Row
54: Key.TAB,
112: Key.LOWER_Q,
118: Key.LOWER_W,
100: Key.LOWER_E,
113: Key.LOWER_R,
115: Key.LOWER_T,
120: Key.LOWER_Y,
116: Key.LOWER_U,
104: Key.LOWER_I,
110: Key.LOWER_O,
111: Key.LOWER_P,
27: Key.CENT,
21: Key.BACKSLASH,
53: Key.BACKTAB,
# Third Row
76: Key.CAPS_LOCK,
96: Key.LOWER_A,
114: Key.LOWER_S,
99: Key.LOWER_D,
101: Key.LOWER_F,
102: Key.LOWER_G,
103: Key.LOWER_H,
105: Key.LOWER_J,
106: Key.LOWER_K,
107: Key.LOWER_L,
126: Key.SEMICOLON,
18: Key.SINGLE_QUOTE,
15: Key.LEFT_BRACE,
8: Key.FIELD_EXIT,
# Fourth Row
77: Key.LEFT_SHIFT,
9: Key.LESS,
121: Key.LOWER_Z,
119: Key.LOWER_X,
98: Key.LOWER_C,
117: Key.LOWER_V,
97: Key.LOWER_B,
109: Key.LOWER_N,
108: Key.LOWER_M,
51: Key.COMMA,
50: Key.PERIOD,
20: Key.SLASH,
78: Key.RIGHT_SHIFT,
# Bottom Row
52: Key.RESET,
16: Key.SPACE,
79: Key.RIGHT_ALT,
24: Key.ENTER,
# Right
95: Key.DUP,
94: Key.FIELD_MARK,
12: Key.INSERT, # TODO: Confirm this mapping
13: Key.DELETE,
14: Key.UP,
19: Key.DOWN,
22: Key.LEFT,
26: Key.RIGHT
}
KEYMAP_SHIFT = {
**KEYMAP_DEFAULT,
# First Row
61: Key.TILDE,
33: Key.BAR,
34: Key.AT,
35: Key.HASH,
36: Key.DOLLAR,
37: Key.PERCENT,
38: Key.NOT,
39: Key.AMPERSAND,
40: Key.ASTERISK,
41: Key.LEFT_PAREN,
32: Key.RIGHT_PAREN,
48: Key.UNDERSCORE,
17: Key.PLUS,
# Second Row
112: Key.UPPER_Q,
118: Key.UPPER_W,
100: Key.UPPER_E,
113: Key.UPPER_R,
115: Key.UPPER_T,
120: Key.UPPER_Y,
116: Key.UPPER_U,
104: Key.UPPER_I,
110: Key.UPPER_O,
111: Key.UPPER_P,
27: Key.EXCLAMATION,
21: Key.BROKEN_BAR,
# Third Row
96: Key.UPPER_A,
114: Key.UPPER_S,
99: Key.UPPER_D,
101: Key.UPPER_F,
102: Key.UPPER_G,
103: Key.UPPER_H,
105: Key.UPPER_J,
106: Key.UPPER_K,
107: Key.UPPER_L,
126: Key.COLON,
18: Key.DOUBLE_QUOTE,
15: Key.RIGHT_BRACE,
# Fourth Row
9: Key.GREATER,
121: Key.UPPER_Z,
119: Key.UPPER_X,
98: Key.UPPER_C,
117: Key.UPPER_V,
97: Key.UPPER_B,
109: Key.UPPER_N,
108: Key.UPPER_M,
51: Key.COMMA, # TODO: Confirm this mapping
50: Key.CENTER_PERIOD,
20: Key.QUESTION
}
KEYMAP_ALT = {
**KEYMAP_DEFAULT,
# Control Keys
80: Key.SYS_RQ,
81: Key.CLEAR,
83: Key.ERASE_INPUT,
84: Key.ALT_CURSOR,
86: Key.IDENT,
87: Key.TEST,
# First Row
33: Key.PF1,
34: Key.PF2,
35: Key.PF3,
36: Key.PF4,
37: Key.PF5,
38: Key.PF6,
39: Key.PF7,
40: Key.PF8,
41: Key.PF9,
32: Key.PF10,
48: Key.PF11,
17: Key.PF12,
# Right
95: Key.PA1,
94: Key.PA2,
# 22 - Unsure what this key is
# 26 - Unsure what this key is
}
MODIFIER_RELEASE_MAP = {
204: Key.CAPS_LOCK,
205: Key.LEFT_SHIFT,
206: Key.RIGHT_SHIFT,
207: Key.RIGHT_ALT
}
KEYMAP = Keymap('3278-2', KEYMAP_DEFAULT, KEYMAP_SHIFT, KEYMAP_ALT, MODIFIER_RELEASE_MAP)

52
oec/terminal.py Normal file
View File

@ -0,0 +1,52 @@
"""
oec.terminal
~~~~~~~~~~~~
"""
from collections import namedtuple
from .display import StatusLine
from .keyboard import Keyboard
from .keymap_3278_2 import KEYMAP
# Does not include the status line row.
Dimensions = namedtuple('Dimensions', ['rows', 'columns'])
MODEL_DIMENSIONS = {
2: Dimensions(24, 80),
3: Dimensions(32, 80),
4: Dimensions(43, 80),
5: Dimensions(27, 132)
}
def get_dimensions(terminal_id, extended_id):
"""Get terminal display dimensions."""
if not terminal_id.model in MODEL_DIMENSIONS:
raise ValueError(f'Model {terminal_id.model} is not supported')
return MODEL_DIMENSIONS[terminal_id.model]
def get_keyboard(terminal_id, extended_id):
"""Get keyboard configured with terminal keymap."""
return Keyboard(KEYMAP)
class Terminal:
"""Terminal information, devices and helpers."""
def __init__(self, interface, terminal_id, extended_id):
self.interface = interface
self.terminal_id = terminal_id
self.extended_id = extended_id
self.dimensions = get_dimensions(self.terminal_id, self.extended_id)
self.keyboard = get_keyboard(self.terminal_id, self.extended_id)
self.status_line = StatusLine(self.interface, self.dimensions.columns)
def clear_screen(self):
"""Clear the screen - including the status line."""
(rows, columns) = self.dimensions
self.interface.offload_write(b'\x00', address=0, repeat=((rows+1)*columns)-1)
self.interface.offload_load_address_counter(columns)

6
requirements.txt Normal file
View File

@ -0,0 +1,6 @@
ptyprocess==0.6.0
pycoax==0.0.1
pyserial==3.4
pyte==0.8.0
sliplib==0.3.0
wcwidth==0.1.7

3
run_unit_tests.sh Executable file
View File

@ -0,0 +1,3 @@
#!/bin/sh
python -m unittest discover tests

0
tests/__init__.py Normal file
View File

4
tests/context.py Normal file
View File

@ -0,0 +1,4 @@
import sys
import os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))

22
tests/test_display.py Normal file
View File

@ -0,0 +1,22 @@
import unittest
import context
from oec.display import encode_ascii_character, encode_string
class EncodeAsciiCharacterTestCase(unittest.TestCase):
def test_mapped_character(self):
self.assertEqual(encode_ascii_character(ord('a')), 0x80)
def test_unmapped_character(self):
self.assertEqual(encode_ascii_character(ord('^')), 0x00)
def test_out_of_range(self):
self.assertEqual(encode_ascii_character(ord('')), 0x00)
class EncodeStringTestCase(unittest.TestCase):
def test_mapped_characters(self):
self.assertEqual(encode_string('Hello, world!'), bytes.fromhex('a7 84 8b 8b 8e 33 00 96 8e 91 8b 83 19'))
def test_unmapped_characters(self):
self.assertEqual(encode_string('Everything ✓'), bytes.fromhex('a4 95 84 91 98 93 87 88 8d 86 00 18'))

120
tests/test_keyboard.py Normal file
View File

@ -0,0 +1,120 @@
import unittest
import context
from oec.keyboard import KeyboardModifiers, Key, Keymap, Keyboard, get_ascii_character_for_key
from oec.keymap_3278_2 import KEYMAP
class KeyboardModifiersTestCase(unittest.TestCase):
def test_is_shift(self):
for modifiers in [KeyboardModifiers.LEFT_SHIFT, KeyboardModifiers.RIGHT_SHIFT, KeyboardModifiers.LEFT_SHIFT | KeyboardModifiers.RIGHT_SHIFT, KeyboardModifiers.LEFT_SHIFT | KeyboardModifiers.LEFT_ALT, KeyboardModifiers.RIGHT_SHIFT | KeyboardModifiers.CAPS_LOCK]:
with self.subTest(modifiers=input):
self.assertTrue(modifiers.is_shift())
def test_not_is_shift(self):
for modifiers in [KeyboardModifiers.NONE, KeyboardModifiers.LEFT_ALT, KeyboardModifiers.RIGHT_ALT, KeyboardModifiers.CAPS_LOCK]:
with self.subTest(modifiers=input):
self.assertFalse(modifiers.is_shift())
def test_is_alt(self):
for modifiers in [KeyboardModifiers.LEFT_ALT, KeyboardModifiers.RIGHT_ALT, KeyboardModifiers.LEFT_ALT | KeyboardModifiers.RIGHT_ALT, KeyboardModifiers.LEFT_ALT | KeyboardModifiers.LEFT_SHIFT, KeyboardModifiers.RIGHT_ALT | KeyboardModifiers.CAPS_LOCK]:
with self.subTest(modifiers=input):
self.assertTrue(modifiers.is_alt())
def test_not_is_alt(self):
for modifiers in [KeyboardModifiers.NONE, KeyboardModifiers.LEFT_SHIFT, KeyboardModifiers.RIGHT_SHIFT, KeyboardModifiers.CAPS_LOCK]:
with self.subTest(modifiers=input):
self.assertFalse(modifiers.is_alt())
def test_is_caps_lock(self):
for modifiers in [KeyboardModifiers.CAPS_LOCK, KeyboardModifiers.CAPS_LOCK | KeyboardModifiers.LEFT_SHIFT, KeyboardModifiers.CAPS_LOCK | KeyboardModifiers.LEFT_ALT]:
with self.subTest(modifiers=input):
self.assertTrue(modifiers.is_caps_lock())
def test_not_is_caps_lock(self):
for modifiers in [KeyboardModifiers.NONE, KeyboardModifiers.LEFT_SHIFT, KeyboardModifiers.RIGHT_SHIFT, KeyboardModifiers.LEFT_ALT, KeyboardModifiers.RIGHT_ALT]:
with self.subTest(modifiers=input):
self.assertFalse(modifiers.is_caps_lock())
class KeyboardGetKeyTestCase(unittest.TestCase):
def setUp(self):
self.keyboard = Keyboard(KEYMAP)
def test_default(self):
self._assert_get_key(96, Key.LOWER_A, KeyboardModifiers.NONE, False)
self._assert_get_key(97, Key.LOWER_B, KeyboardModifiers.NONE, False)
self._assert_get_key(98, Key.LOWER_C, KeyboardModifiers.NONE, False)
self._assert_get_key(99, Key.LOWER_D, KeyboardModifiers.NONE, False)
def test_shift(self):
self._assert_get_key(96, Key.LOWER_A, KeyboardModifiers.NONE, False)
self._assert_get_key(77, Key.LEFT_SHIFT, KeyboardModifiers.LEFT_SHIFT, True)
self._assert_get_key(97, Key.UPPER_B, KeyboardModifiers.LEFT_SHIFT, False)
self._assert_get_key(78, Key.RIGHT_SHIFT, KeyboardModifiers.LEFT_SHIFT | KeyboardModifiers.RIGHT_SHIFT, True)
self._assert_get_key(98, Key.UPPER_C, KeyboardModifiers.LEFT_SHIFT | KeyboardModifiers.RIGHT_SHIFT, False)
self._assert_get_key(205, None, KeyboardModifiers.RIGHT_SHIFT, True)
self._assert_get_key(99, Key.UPPER_D, KeyboardModifiers.RIGHT_SHIFT, False)
self._assert_get_key(206, None, KeyboardModifiers.NONE, True)
self._assert_get_key(100, Key.LOWER_E, KeyboardModifiers.NONE, False)
# TODO... include the additional ALT reset scan_code!
def test_mapped_alt(self):
self._assert_get_key(96, Key.LOWER_A, KeyboardModifiers.NONE, False)
self._assert_get_key(79, Key.RIGHT_ALT, KeyboardModifiers.RIGHT_ALT, True)
self._assert_get_key(33, Key.PF1, KeyboardModifiers.RIGHT_ALT, False)
self._assert_get_key(207, None, KeyboardModifiers.NONE, True)
self._assert_get_key(98, Key.LOWER_C, KeyboardModifiers.NONE, False)
def test_unmapped_alt(self):
self._assert_get_key(96, Key.LOWER_A, KeyboardModifiers.NONE, False)
self._assert_get_key(79, Key.RIGHT_ALT, KeyboardModifiers.RIGHT_ALT, True)
self._assert_get_key(97, Key.LOWER_B, KeyboardModifiers.RIGHT_ALT, False)
self._assert_get_key(207, None, KeyboardModifiers.NONE, True)
self._assert_get_key(98, Key.LOWER_C, KeyboardModifiers.NONE, False)
def test_alt_and_shift(self):
self._assert_get_key(96, Key.LOWER_A, KeyboardModifiers.NONE, False)
self._assert_get_key(79, Key.RIGHT_ALT, KeyboardModifiers.RIGHT_ALT, True)
self._assert_get_key(97, Key.LOWER_B, KeyboardModifiers.RIGHT_ALT, False)
self._assert_get_key(77, Key.LEFT_SHIFT, KeyboardModifiers.RIGHT_ALT | KeyboardModifiers.LEFT_SHIFT, True)
self._assert_get_key(98, Key.UPPER_C, KeyboardModifiers.RIGHT_ALT | KeyboardModifiers.LEFT_SHIFT, False)
self._assert_get_key(205, None, KeyboardModifiers.RIGHT_ALT, True)
self._assert_get_key(99, Key.LOWER_D, KeyboardModifiers.RIGHT_ALT, False)
self._assert_get_key(207, None, KeyboardModifiers.NONE, True)
self._assert_get_key(100, Key.LOWER_E, KeyboardModifiers.NONE, False)
def test_caps_lock(self):
self._assert_get_key(96, Key.LOWER_A, KeyboardModifiers.NONE, False)
self._assert_get_key(76, Key.CAPS_LOCK, KeyboardModifiers.CAPS_LOCK, True)
self._assert_get_key(204, None, KeyboardModifiers.CAPS_LOCK, False)
self._assert_get_key(97, Key.UPPER_B, KeyboardModifiers.CAPS_LOCK, False)
self._assert_get_key(76, Key.CAPS_LOCK, KeyboardModifiers.NONE, True)
self._assert_get_key(204, None, KeyboardModifiers.NONE, False)
self._assert_get_key(98, Key.LOWER_C, KeyboardModifiers.NONE, False)
def test_caps_lock_and_shift(self):
self._assert_get_key(96, Key.LOWER_A, KeyboardModifiers.NONE, False)
self._assert_get_key(76, Key.CAPS_LOCK, KeyboardModifiers.CAPS_LOCK, True)
self._assert_get_key(204, None, KeyboardModifiers.CAPS_LOCK, False)
self._assert_get_key(97, Key.UPPER_B, KeyboardModifiers.CAPS_LOCK, False)
self._assert_get_key(77, Key.LEFT_SHIFT, KeyboardModifiers.CAPS_LOCK | KeyboardModifiers.LEFT_SHIFT, True)
self._assert_get_key(98, Key.LOWER_C, KeyboardModifiers.CAPS_LOCK | KeyboardModifiers.LEFT_SHIFT, False)
self._assert_get_key(205, None, KeyboardModifiers.CAPS_LOCK, True)
self._assert_get_key(99, Key.UPPER_D, KeyboardModifiers.CAPS_LOCK, False)
self._assert_get_key(76, Key.CAPS_LOCK, KeyboardModifiers.NONE, True)
self._assert_get_key(204, None, KeyboardModifiers.NONE, False)
self._assert_get_key(100, Key.LOWER_E, KeyboardModifiers.NONE, False)
def _assert_get_key(self, scan_code, key, modifiers, modifiers_changed):
self.assertEqual(self.keyboard.get_key(scan_code), (key, modifiers, modifiers_changed))
class GetAsciiCharacterForKeyTestCase(unittest.TestCase):
def test_none(self):
self.assertIsNone(get_ascii_character_for_key(None))
def test_no_mapping(self):
self.assertIsNone(get_ascii_character_for_key(Key.ATTN))
def test_mapping(self):
self.assertEqual(get_ascii_character_for_key(Key.UPPER_A), 'A')