Repository: nvaccess/nvda

Issue:
Seika Braille Display can't be loaded in NVDA 2019.3
Source code is the [seika.py](https://github.com/nvaccess/nvda/blob/master/source/brailleDisplayDrivers/seika.py).

### Steps to reproduce:
1. CP2102 installed successfully and the serial COM port of the Seika braille display can be found.
2. Manually select the "Seika Braille Displays", but only got "Could not load the seika display.".

### Actual behavior:
The Seika Braille display can't be used in the NVDA 2019.3 and NVDA 2019.3.1.

### Expected behavior:

### System configuration
#### NVDA installed/portable/running from source:

#### NVDA version:
NVDA 2019.3 AND NVDA 2019.3.1
#### Windows version:
Windwos 10 1903 64bit.
Windows 10 1909 64bit.
#### Name and version of other software in use when reproducing the issue:

#### Other information about your system:

### Other questions
#### Does the issue still occur after restarting your PC?
Yes.
#### Have you tried any other versions of NVDA? If so, please report their behaviors.
The Seika Braille display works fine in the NVDA 2019.2 and older version.


Discussion:
Hi, this suggests incomplete Python 3 porting. To verify this, we need a log output from this error. CC @feerrenrut
Hi @josephsl ,
Thanks for the reply.
We will try to look into and improve the seika.py, and test manually.
We have contacted the author @Ulf-Beckmann too.
BTW, we are the manufacturer of the Seika Braille Display.

Hi @josephsl ,
We have updated the "seika.py" and well test it. The Seika Braille displays are work fine with the NVDA 2019.3.
We rename the name of the driver to the "Seika Braille V3/5/80" from "Seika Braille displays". Please find the addon here 
[Seika v3v5v80 addon for NADA v1.0.zip](https://github.com/nvaccess/nvda/files/4211653/Seika.v3v5v80.addon.for.NADA.v1.0.zip)
.
Is there a need for the PR for this issue?
Hi, PR please, at least for 2020.1 and later. For 2019.3.1, the add-on would suffice. Thanks.

Resolving patch:
diff --git a/source/braille.py b/source/braille.py
--- a/source/braille.py
+++ b/source/braille.py
@@ -2320,11 +2320,13 @@ def terminate(self):
 			# The display driver seems to be failing, but we're terminating anyway, so just ignore it.
 			pass
 
-	def _get_numCells(self):
+	#: typing information for autoproperty _get_numCells
+	numCells: int
+
+	def _get_numCells(self) -> int:
 		"""Obtain the number of braille cells on this  display.
 		@note: 0 indicates that braille should be disabled.
 		@return: The number of cells.
-		@rtype: int
 		"""
 		return 0
 
diff --git a/source/brailleDisplayDrivers/seika.py b/source/brailleDisplayDrivers/seika.py
--- a/source/brailleDisplayDrivers/seika.py
+++ b/source/brailleDisplayDrivers/seika.py
@@ -1,35 +1,30 @@
-# -*- coding: UTF-8 -*-
-#brailleDisplayDrivers/seika.py
-#A part of NonVisual Desktop Access (NVDA)
-#This file is covered by the GNU General Public License.
-#See the file COPYING for more details.
-#Copyright (C) 2012 Ulf Beckmann <beckmann@flusoft.de>
-
-# Parts of this code are inherited from the baum braille driver
-# written by James Teh <jamie@jantrid.net>
+# A part of NonVisual Desktop Access (NVDA)
+# Copyright (C) 2012-2021 NV Access Limited, Ulf Beckmann <beckmann@flusoft.de>
+# This file may be used under the terms of the GNU General Public License, version 2 or later.
+# For more details see: https://www.gnu.org/licenses/gpl-2.0.html
 
 # This file represents the braille display driver for
-# Seika3 V2.00, Seika80, a product from Nippon Telesoft
+# Seika3/5 V1.x/2.0, Seika80, a product from Nippon Telesoft
 # see www.seika-braille.com for more details
-# 18.08.2012 13:54
 
 from typing import List
-
 import wx
 import serial
 import braille
 import inputCore
 import hwPortUtils
+from hwIo import intToByte
 from logHandler import log
 
 TIMEOUT = 0.2
 BAUDRATE = 9600
 READ_INTERVAL = 50
+BUF_START = b"\xFF\xFF"
 
 class BrailleDisplayDriver(braille.BrailleDisplayDriver):
 	name = "seika"
 	# Translators: Names of braille displays.
-	description = _("Seika braille displays")
+	description = _("Seika Braille Displays")
 	numCells = 0
 
 	@classmethod
@@ -38,48 +33,73 @@ def check(cls):
 
 	def __init__(self):
 		super(BrailleDisplayDriver, self).__init__()
-
 		for portInfo in hwPortUtils.listComPorts(onlyAvailable=True):
 			port = portInfo["port"]
 			hwID = portInfo["hardwareID"]
-			# log.info("Found port {port} with hardwareID {hwID}".format(port=port, hwID=hwID))
-			if not hwID.upper().startswith(r"USB\VID_10C4&PID_EA60"): # Seika USB to Serial, in XP it is lowercase, in Win7 uppercase
+
+			# Seika USB to Serial, in XP it is lowercase, in Win7 uppercase
+			if not hwID.upper().startswith(r"USB\VID_10C4&PID_EA60"):
 				continue
+
 			# At this point, a port bound to this display has been found.
 			# Try talking to the display.
 			try:
-				self._ser = serial.Serial(port, baudrate=BAUDRATE, timeout=TIMEOUT, writeTimeout=TIMEOUT, parity=serial.PARITY_ODD, bytesize=serial.EIGHTBITS, stopbits=serial.STOPBITS_ONE)
+				self._ser = serial.Serial(
+					port,
+					baudrate=BAUDRATE,
+					timeout=TIMEOUT,
+					writeTimeout=TIMEOUT,
+					parity=serial.PARITY_ODD,
+					bytesize=serial.EIGHTBITS,
+					stopbits=serial.STOPBITS_ONE
+				)
 			except serial.SerialException:
 				continue
-			log.debug("serial port open {port}".format(port=port))
-			self._ser.write(b"\xFF\xFF\x1C")
+
+			log.debug(f"serial port open {port}")
+
+			# get the version information
+			VERSION_INFO_REQUEST = b"\x1C"
+			self._ser.write(BUF_START + VERSION_INFO_REQUEST)
 			self._ser.flush()
+
 			# Read out the input buffer
 			versionS = self._ser.read(13)
-			log.debug("receive {p}".format(p=versionS))
-			if versionS.startswith("seika80"):
-				log.info("Found Seika80 connected via {port} Version {versionS}".format(port=port, versionS=versionS))
+			log.debug(f"receive {versionS}")
+
+			if versionS.startswith(b"seika80"):
+				log.info(f"Found Seika80 connected via {port} Version {versionS}")
 				self.numCells = 80
+				self._maxCellRead = 20
+				# data header for seika 80
+				self.sendHeader = (BUF_START + b"s80").ljust(8, b"\x00")
 				break
-			if versionS.startswith("seika3"):
-				log.info("Found Seika40 connected via {port} Version {versionS}".format(port=port, versionS=versionS))
+
+			elif versionS.startswith(b"seika3"):
+				log.info(f"Found Seika3/5 connected via {port} Version {versionS}")
 				self.numCells = 40
-				self.s40 = b"\xFF\xFF\x73\x65\x69\x6B\x61\x00"
+				self._maxCellRead = 10
+				# data header for v3, v5
+				self.sendHeader = (BUF_START + b"seika").ljust(8, b"\x00")
 				break
+
 			# is it a old Seika3?
 			log.debug("test if it is a old Seika3")
-			self._ser.write(b"\xFF\xFF\x0A")
+			LEGACY_VERSION_REQUEST = b"\x0A"
+			self._ser.write(BUF_START + LEGACY_VERSION_REQUEST)
 			self._ser.flush()
+
 			# Read out the input buffer
 			versionS = self._ser.read(12)
-			log.debug("receive {p}".format(p=versionS))
-			if versionS.startswith((
-				b"\x00\x05\x28\x08\x76\x35\x2E\x30\x01\x01\x01\x01",
-				b"\x00\x05\x28\x08\x73\x65\x69\x6b\x61\x00"
+			log.debug(f"receive {versionS}")
+			if versionS.startswith(prefix=(
+				b'\x00\x05(\x08v5.0\x01\x01\x01\x01',
+				b'\x00\x05(\x08seika\x00'
 			)):
-				log.info("Found Seika3 old Version connected via {port} Version {versionS}".format(port=port, versionS=versionS))
+				log.info(f"Found Seika3 old Version connected via {port} Version {versionS}")
 				self.numCells = 40
-				self.s40 = b"\xFF\xFF\x04\x00\x63\x00\x50\x00"
+				self._maxCellRead = 10
+				self.sendHeader = BUF_START + b"\x04\x00\x63\x00\x50\x00"
 				break
 			self._ser.close()
 		else:
@@ -96,72 +116,73 @@ def terminate(self):
 			self._ser.close()
 
 	def display(self, cells: List[int]):
-		# every transmitted line consists of the preamble SEIKA_SENDHEADER and the Cells
-		if self.numCells==80:
-			lineBytes = b"".join([
-				b"\xff\xff\x73\x38\x30\x00\x00\x00",
-				bytes(cells)
-			])
+		# every transmitted line consists of the preamble 'sendHeader' and the Cells
+		if 80 == self.numCells:
+			lineBytes: bytes = self.sendHeader + bytes(cells)
+		elif 40 == self.numCells:
+			cellData = (b"\x00" + intToByte(cell) for cell in cells)
+			lineBytes = self.sendHeader + b"".join(cellData)
 		else:
-			lineBytes = b"".join([
-				self.s40,
-				b"\0",
-				bytes(cells)
-			])
+			log.error("Unsupported cell count for seika braille device")
+			return
 		self._ser.write(lineBytes)
 
 	def handleResponses(self):
 		if not self._ser.in_waiting:
 			return
-		key = 0
-		keys = set()
-		maxCellRead = self.numCells // 4  # for 80 maxCellRead is 20, for 40 cell maxCellRead is 10
+
 		chars: bytes = self._ser.read(2)
-		keytyp=1
-		if not chars[0] & 0x60: # a cursorrouting block is expected
-			chars: bytes = self._ser.read(maxCellRead)
-			keytyp=2
-		# log.info("Seika K {c}".format(c=chars))
-		if keytyp == 1: # normal key
-			if chars[0] & 1: # LEFT
-				keys.add("left")
-			if chars[0] & 4: # RIGHT
-				keys.add("right")
-			if chars[1] & 2: # B1 
-				keys.add("b1")
-			if chars[1] & 8:
-				keys.add("b2")
-			if chars[1] & 16:
-				keys.add("b3")
-			if chars[0] & 16:
-				keys.add("b4")
-			if chars[0] & 8:
-				keys.add("b5")
-			if chars[0] & 2:
-				keys.add("b6")
-			data= "+".join(keys)
+		isCursorRoutingBlock = not chars[0] & 0x60
+		
+		if not isCursorRoutingBlock:  # normal key
+			self._handleNormalKey(chars)
+		else:  # Cursor Routing Block
+			chars: bytes = self._ser.read(self._maxCellRead)
+			self._handleCursorRoutingBlock(chars)
+
+	def _handleCursorRoutingBlock(self, chars: bytes) -> None:
+		key = 0
+		i = 0
+		k = self._maxCellRead // 2
+		while i < k:
+			j = 0
+			while j < 8:
+				if chars[5 + i] & (1 << j):
+					key = i * 8 + j + 1
+					break
+				j += 1
+			i += 1
+		if key:  # Routing key is pressed
 			try:
-				inputCore.manager.executeGesture(InputGestureKeys(data))
+				inputCore.manager.executeGesture(InputGestureRouting(key - 1))
 			except inputCore.NoInputGestureAction:
-				log.debug("No Action for keys {keys}".format(keys=data))
+				log.debug("No Action for routing command")
 				pass
-		else:
-			i = 0
-			k = maxCellRead // 2
-			while i < k:
-				j = 0
-				while j < 8:
-					if chars[5+i] & (1<<j):
-						key = i*8+j+1
-						break
-					j+=1
-				i+=1
-			if key: # Routing key is pressed
-				try:
-					inputCore.manager.executeGesture(InputGestureRouting(key-1))
-				except inputCore.NoInputGestureAction:
-					log.debug("No Action for routing command")
-					pass
+
+	def _handleNormalKey(self, chars: bytes) -> None:
+		keys = set()
+		if chars[0] & 1:  # LEFT
+			keys.add("left")
+		if chars[0] & 4:  # RIGHT
+			keys.add("right")
+		if chars[1] & 2:  # B1
+			keys.add("b1")
+		if chars[1] & 8:
+			keys.add("b2")
+		if chars[1] & 16:
+			keys.add("b3")
+		if chars[0] & 16:
+			keys.add("b4")
+		if chars[0] & 8:
+			keys.add("b5")
+		if chars[0] & 2:
+			keys.add("b6")
+		data = "+".join(keys)
+		try:
+			inputCore.manager.executeGesture(InputGestureKeys(data))
+		except inputCore.NoInputGestureAction:
+			log.debug(f"No Action for keys {data}")
+			pass
 
 	gestureMap = inputCore.GlobalGestureMap({
 		"globalCommands.GlobalCommands": {
@@ -179,15 +200,16 @@ def handleResponses(self):
 		},
 	})
 
-class InputGestureKeys(braille.BrailleDisplayGesture):
 
+class InputGestureKeys(braille.BrailleDisplayGesture):
 	source = BrailleDisplayDriver.name
+
 	def __init__(self, keys):
 		super(InputGestureKeys, self).__init__()
 		self.id = keys
 
-class InputGestureRouting(braille.BrailleDisplayGesture):
 
+class InputGestureRouting(braille.BrailleDisplayGesture):
 	source = BrailleDisplayDriver.name
 
 	def __init__(self, index):
diff --git a/source/brailleDisplayDrivers/seikantk.py b/source/brailleDisplayDrivers/seikantk.py
new file mode 100644
--- /dev/null
+++ b/source/brailleDisplayDrivers/seikantk.py
@@ -0,0 +1,311 @@
+# A part of NonVisual Desktop Access (NVDA)
+# Copyright (C) 2012-2021 NV Access Limited, Ulf Beckmann <beckmann@flusoft.de>
+# This file may be used under the terms of the GNU General Public License, version 2 or later.
+# For more details see: https://www.gnu.org/licenses/gpl-2.0.html
+#
+# This file represents the braille display driver for
+# Seika Notetaker, a product from Nippon Telesoft
+# see www.seika-braille.com for more details
+
+from io import BytesIO
+import typing
+from typing import List
+
+import braille
+import brailleInput
+import inputCore
+import hwPortUtils
+import bdDetect
+import hwIo
+from serial.win32 import INVALID_HANDLE_VALUE
+from logHandler import log
+
+MAX_READ_ATTEMPTS = 30
+READ_TIMEOUT_SECS = 0.2
+
+DOT_1 = 0x1
+DOT_2 = 0x2
+DOT_3 = 0x4
+DOT_4 = 0x8
+DOT_5 = 0x10
+DOT_6 = 0x20
+DOT_7 = 0x40
+DOT_8 = 0x80
+
+
+_keyNames = {
+	0x000001: "BACKSPACE",
+	0x000002: "SPACE",
+	0x000004: "LB",
+	0x000008: "RB",
+	0x000010: "LJ_CENTER",
+	0x000020: "LJ_LEFT",
+	0x000040: "LJ_RIGHT",
+	0x000080: "LJ_UP",
+	0x000100: "LJ_DOWN",
+	0x000200: "RJ_CENTER",
+	0x000400: "RJ_LEFT",
+	0x000800: "RJ_RIGHT",
+	0x001000: "RJ_UP",
+	0x002000: "RJ_DOWN"
+}
+
+SEIKA_REQUEST_INFO = b"\x03\xff\xff\xa1"
+SEIKA_INFO = b"\xff\xff\xa2"
+SEIKA_SEND_TEXT = b"\x2c\xff\xff\xa3"
+SEIKA_ROUTING = b"\xff\xff\xa4"
+SEIKA_KEYS = b"\xff\xff\xa6"
+SEIKA_KEYS_ROU = b"\xff\xff\xa8"
+
+SEIKA_CONFIG = b"\x50\x00\x00\x25\x80\x00\x00\x03\x00"
+SEIKA_CMD_ON = b"\x41\x01"
+
+vidpid = "VID_10C4&PID_EA80"
+hidvidpid = "HID\\VID_10C4&PID_EA80"
+SEIKA_NAME = "seikantk"
+
+
+def _getDotNames():
+	dotNames = {}
+	for dotNum in range(1, 9):
+		keyName = globals()[f"DOT_{dotNum}"]
+		dotNames[keyName] = f"d{dotNum}"
+	return dotNames
+
+
+_dotNames = _getDotNames()
+bdDetect.addUsbDevices(SEIKA_NAME, bdDetect.KEY_HID, {vidpid, })
+
+
+class BrailleDisplayDriver(braille.BrailleDisplayDriver):
+	_dev: hwIo.IoBase
+	name = SEIKA_NAME
+	# Translators: Name of a braille display.
+	description = _("Seika Notetaker")
+	path = ""
+	isThreadSafe = True
+	for d in hwPortUtils.listHidDevices():
+		if d["hardwareID"].startswith(hidvidpid):
+			path = d["devicePath"]
+
+	@classmethod
+	def check(cls):
+		return True
+
+	@classmethod
+	def getManualPorts(cls):
+		return cls.path
+
+	def __init__(self, port="hid"):
+		super().__init__()
+		self.numCells = 0
+		self.numBtns = 0
+		self.handle = None
+
+		self._hidBuffer = b""
+		self._command: typing.Optional[bytes] = None
+		self._argsLen: typing.Optional[int] = None
+		log.info(f"Seika Notetaker braille driver path: {self.path}")
+
+		if self.path == "":
+			raise RuntimeError("No MINI-SEIKA display found, no path found")
+		self._dev = dev = hwIo.Hid(path=self.path, onReceive=self._onReceive)
+		if dev._file == INVALID_HANDLE_VALUE:
+			raise RuntimeError("No MINI-SEIKA display found, open error")
+		dev.setFeature(SEIKA_CONFIG)  # baud rate, stop bit usw
+		dev.setFeature(SEIKA_CMD_ON)  # device on
+		dev.write(SEIKA_REQUEST_INFO)  # Request the Info from the device
+
+		# wait and try to get info from the Braille display
+		for i in range(MAX_READ_ATTEMPTS):  # the info-block is about
+			dev.waitForRead(READ_TIMEOUT_SECS)
+			if self.numCells:
+				log.info(
+					f"Seika notetaker on USB-HID,"
+					f" Cells {self.numCells}"
+					f" Buttons {self.numBtns}"
+				)
+				break
+
+		if self.numCells == 0:
+			dev.close()
+			raise RuntimeError("No MINI-SEIKA display found, no response")
+
+	def terminate(self):
+		try:
+			super().terminate()
+		finally:
+			self._dev.close()
+
+	def display(self, cells: List[int]):
+		# cells will already be padded up to numCells.
+		cellBytes = SEIKA_SEND_TEXT + self.numCells.to_bytes(1, 'little') + bytes(cells)
+		self._dev.write(cellBytes)
+
+	def _onReceive(self, data: bytes):
+		"""
+		Note: Further insight into this function would be greatly appreciated.
+		This function is a very simple state machine, each stage represents the collection of a field, when all
+		fields are collected the command they represent can be processed.
+
+		On each call to _onReceive three bytes are read from the device.
+		The first and third bytes are discarded, the second byte is appended to a buffer.
+		The buffer is accumulated until the buffer has the required number of bytes for the field being collected.
+		There are 3 fields to be collected before a command can be processed:
+		1: first 3 bytes: command
+		2: 1 byte: specify total length in bytes?
+		3: variable length: arguments for command type
+
+		After accumulating enough bytes for each phase, the buffer is cleared and the next stage is entered.
+		"""
+		COMMAND_LEN = 3
+		stream = BytesIO(data)
+		cmd = stream.read(3)  # Note, first and third bytes are discarded
+		newByte: bytes = cmd[1:2]  # use range to return bytes
+		self._hidBuffer += newByte
+		hasCommandBeenCollected = self._command is None
+		hasArgLenBeenCollected = self._argsLen is None
+		if (  # still collecting command bytes
+			not hasCommandBeenCollected
+			and len(self._hidBuffer) == COMMAND_LEN
+		):
+			self._command = self._hidBuffer  # command found reset and wait for args length
+			self._hidBuffer = None
+		elif (  # next byte gives the command + args length
+			hasCommandBeenCollected
+			and not hasArgLenBeenCollected  # argsLen has not
+		):
+			# Unknown why we must wait for 4 extra bytes. Without a device to inspect actual data
+			# it has to be assumed that the prior approach is correct, and infer what we can from
+			# it.
+			# Best guess: the data is sent with the following structure
+			# - command name (3 bytes)
+			# - total bytes Command + Args size (1 byte)
+			# - Args (variable bytes)
+			# - Constant 4 bytes containing unknown
+			self._argsLen = ord(newByte) - COMMAND_LEN + 4
+			# don't reset _hidBuffer the value for total length
+		elif (  # now collect the args,
+			hasCommandBeenCollected
+			and hasArgLenBeenCollected
+			and len(self._hidBuffer) == self._argsLen
+		):
+			arg = self._hidBuffer
+			command = self._command
+
+			# reset state variables
+			self._command = None
+			self._argsLen = None
+			self._hidBuffer = b""
+			self._processCommand(command, arg)
+
+	def _processCommand(self, command: bytes, arg: bytes) -> None:
+		if command == SEIKA_INFO:
+			self._handInfo(arg)
+		elif command == SEIKA_ROUTING:
+			self._handRouting(arg)
+		elif command == SEIKA_KEYS:
+			self._handKeys(arg)
+		elif command == SEIKA_KEYS_ROU:
+			self._handKeysRouting(arg)
+		else:
+			log.warning(f"Seika device has received an unknown command {command}")
+
+	def _handInfo(self, arg: bytes):
+		self.numCells = arg[2]
+		self.numBtns = arg[1]
+
+	def _handRouting(self, arg: bytes):
+		for i in range(arg[0]):
+			for j in range(8):
+				if arg[i + 1] & (1 << j):
+					routingIndex = i * 8 + j
+					gesture = InputGestureRouting(routingIndex)
+					try:
+						inputCore.manager.executeGesture(gesture)
+					except inputCore.NoInputGestureAction:
+						log.debug("No action for Seika Notetaker routing command")
+
+	def _handKeys(self, arg: bytes):
+		brailleDots = arg[1]
+		key = arg[2] | (arg[3] << 8)
+		gesture = None
+		if key:  # Mini Seika has 2 Top and 4 Front
+			gesture = InputGesture(keys=key)
+		if brailleDots:
+			gesture = InputGesture(dots=brailleDots)
+		if gesture is not None:
+			try:
+				inputCore.manager.executeGesture(gesture)
+			except inputCore.NoInputGestureAction:
+				log.debug("No action for Seika Notetaker keys.")
+
+	def _handKeysRouting(self, arg: bytes):
+		argk = b"\x03" + arg[1:]
+		argr = (arg[0] - 3).to_bytes(1, 'little') + arg[4:]
+		self._handRouting(argr)
+		self._handKeys(argk)
+
+	gestureMap = inputCore.GlobalGestureMap({
+		"globalCommands.GlobalCommands": {
+			"braille_routeTo": ("br(seikantk):routing",),
+			"braille_scrollBack": ("br(seikantk):LB",),
+			"braille_scrollForward": ("br(seikantk):RB",),
+			"braille_previousLine": ("br(seikantk):LJ_UP",),
+			"braille_nextLine": ("br(seikantk):LJ_DOWN",),
+			"braille_toggleTether": ("br(seikantk):LJ_CENTER",),
+			"sayAll": ("br(seikantk):SPACE+BACKSPACE",),
+			"showGui": ("br(seikantk):RB+LB",),
+			"kb:tab": ("br(seikantk):LJ_RIGHT",),
+			"kb:shift+tab": ("br(seikantk):LJ_LEFT",),
+			"kb:upArrow": ("br(seikantk):RJ_UP",),
+			"kb:downArrow": ("br(seikantk):RJ_DOWN",),
+			"kb:leftArrow": ("br(seikantk):RJ_LEFT",),
+			"kb:rightArrow": ("br(seikantk):RJ_RIGHT",),
+			"kb:shift+upArrow": ("br(seikantk):SPACE+RJ_UP", "br(seikantk):BACKSPACE+RJ_UP"),
+			"kb:shift+downArrow": ("br(seikantk):SPACE+RJ_DOWN", "br(seikantk):BACKSPACE+RJ_DOWN"),
+			"kb:shift+leftArrow": ("br(seikantk):SPACE+RJ_LEFT", "br(seikantk):BACKSPACE+RJ_LEFT"),
+			"kb:shift+rightArrow": ("br(seikantk):SPACE+RJ_RIGHT", "br(seikantk):BACKSPACE+RJ_RIGHT"),
+			"kb:escape": ("br(seikantk):SPACE+RJ_CENTER",),
+			"kb:windows": ("br(seikantk):BACKSPACE+RJ_CENTER",),
+			"kb:space": ("br(seikantk):BACKSPACE", "br(seikantk):SPACE",),
+			"kb:backspace": ("br(seikantk):d7",),
+			"kb:pageup": ("br(seikantk):SPACE+LJ_RIGHT",),
+			"kb:pagedown": ("br(seikantk):SPACE+LJ_LEFT",),
+			"kb:home": ("br(seikantk):SPACE+LJ_UP",),
+			"kb:end": ("br(seikantk):SPACE+LJ_DOWN",),
+			"kb:control+home": ("br(seikantk):BACKSPACE+LJ_UP",),
+			"kb:control+end": ("br(seikantk):BACKSPACE+LJ_DOWN",),
+			"kb:enter": ("br(seikantk):RJ_CENTER", "br(seikantk):d8"),
+		},
+	})
+
+
+class InputGestureRouting(braille.BrailleDisplayGesture):
+	source = BrailleDisplayDriver.name
+
+	def __init__(self, index):
+		super().__init__()
+		self.id = "routing"
+		self.routingIndex = index
+
+
+class InputGesture(braille.BrailleDisplayGesture, brailleInput.BrailleInputGesture):
+	source = BrailleDisplayDriver.name
+
+	def __init__(self, keys=None, dots=None, space=False, routing=None):
+		super(braille.BrailleDisplayGesture, self).__init__()
+		# see what thumb keys are pressed:
+		names = set()
+		if keys is not None:
+			names.update(_keyNames[1 << i] for i in range(22) if (1 << i) & keys)
+		elif dots is not None:
+			self.dots = dots
+			if space:
+				self.space = space
+				names.add(_keyNames[1])
+			names.update(_dotNames[1 << i] for i in range(8) if (1 << i) & dots)
+		elif routing is not None:
+			self.routingIndex = routing
+			names.add('routing')
+		self.id = "+".join(names)


Test patch: