commit 7c2969307bbf4f9f6131cd48788bbf65f292b7df Author: Andrew Kay Date: Sat Jun 15 22:03:26 2019 -0500 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bdf77fd --- /dev/null +++ b/.gitignore @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1b712bb --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..47e384d --- /dev/null +++ b/README.md @@ -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 diff --git a/oec/__main__.py b/oec/__main__.py new file mode 100644 index 0000000..e740e6a --- /dev/null +++ b/oec/__main__.py @@ -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() diff --git a/oec/controller.py b/oec/controller.py new file mode 100644 index 0000000..b99467f --- /dev/null +++ b/oec/controller.py @@ -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 diff --git a/oec/display.py b/oec/display.py new file mode 100644 index 0000000..7947eed --- /dev/null +++ b/oec/display.py @@ -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)) diff --git a/oec/emulator.py b/oec/emulator.py new file mode 100644 index 0000000..0b3db50 --- /dev/null +++ b/oec/emulator.py @@ -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 diff --git a/oec/keyboard.py b/oec/keyboard.py new file mode 100644 index 0000000..6952af1 --- /dev/null +++ b/oec/keyboard.py @@ -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) diff --git a/oec/keymap_3278_2.py b/oec/keymap_3278_2.py new file mode 100644 index 0000000..a9bdcc2 --- /dev/null +++ b/oec/keymap_3278_2.py @@ -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) diff --git a/oec/terminal.py b/oec/terminal.py new file mode 100644 index 0000000..f7b8417 --- /dev/null +++ b/oec/terminal.py @@ -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) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e22b274 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/run_unit_tests.sh b/run_unit_tests.sh new file mode 100755 index 0000000..62ce822 --- /dev/null +++ b/run_unit_tests.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +python -m unittest discover tests diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/context.py b/tests/context.py new file mode 100644 index 0000000..1581836 --- /dev/null +++ b/tests/context.py @@ -0,0 +1,4 @@ +import sys +import os + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) diff --git a/tests/test_display.py b/tests/test_display.py new file mode 100644 index 0000000..e5aab23 --- /dev/null +++ b/tests/test_display.py @@ -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')) diff --git a/tests/test_keyboard.py b/tests/test_keyboard.py new file mode 100644 index 0000000..d76855d --- /dev/null +++ b/tests/test_keyboard.py @@ -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')