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

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)