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