mirror of
https://github.com/lowobservable/oec.git
synced 2026-01-11 23:53:04 +00:00
Initial commit
This commit is contained in:
commit
7c2969307b
112
.gitignore
vendored
Normal file
112
.gitignore
vendored
Normal 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
13
LICENSE
Normal 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
27
README.md
Normal 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
42
oec/__main__.py
Normal 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
192
oec/controller.py
Normal 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
161
oec/display.py
Normal 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
250
oec/emulator.py
Normal 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
360
oec/keyboard.py
Normal 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
198
oec/keymap_3278_2.py
Normal 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
52
oec/terminal.py
Normal 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
6
requirements.txt
Normal 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
3
run_unit_tests.sh
Executable file
@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
|
||||
python -m unittest discover tests
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
4
tests/context.py
Normal file
4
tests/context.py
Normal 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
22
tests/test_display.py
Normal 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
120
tests/test_keyboard.py
Normal 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')
|
||||
Loading…
x
Reference in New Issue
Block a user