Repository: nvaccess/nvda

Issue:
BrailleNote: Support for BrailleNote Touch via USB HID/new protocol
Hi,

I haven't forgotten about my work on supporting Apex's scroll wheel and others...

According to a HumanWare staff, BrailleNote Touch firmware 2.0 includes a braille display mode to allow it to act as a braille display, with the Touch using USB HID and a new protocol (no more need for a custom USB serial driver). I plan to ask HumanWare AU to let @jcsteh have a demo unit so he can investigate this further.

Thanks.


Discussion:

Resolving patch:
diff --git a/source/brailleDisplayDrivers/brailliantB.py b/source/brailleDisplayDrivers/brailliantB.py
--- a/source/brailleDisplayDrivers/brailliantB.py
+++ b/source/brailleDisplayDrivers/brailliantB.py
@@ -7,6 +7,7 @@
 import os
 import _winreg
 import itertools
+import time
 import serial
 import hwPortUtils
 import braille
@@ -18,6 +19,9 @@
 TIMEOUT = 0.2
 BAUD_RATE = 115200
 PARITY = serial.PARITY_EVEN
+DELAY_AFTER_CONNECT = 1.0
+INIT_ATTEMPTS = 3
+INIT_RETRY_DELAY = 0.2
 
 # Serial
 HEADER = "\x1b"
@@ -34,7 +38,8 @@
 HR_POWEROFF = "\x07"
 
 KEY_NAMES = {
-	# Braille keyboard.
+	1: "power", # Brailliant BI 32, 40 and 80.
+	# Braille keyboard (all devices except Brailliant 80).
 	2: "dot1",
 	3: "dot2",
 	4: "dot3",
@@ -44,62 +49,88 @@
 	8: "dot7",
 	9: "dot8",
 	10: "space",
-	# Command keys.
+	# Command keys (Brailliant BI 32, 40 and 80).
 	11: "c1",
 	12: "c2",
 	13: "c3",
 	14: "c4",
 	15: "c5",
 	16: "c6",
-	# Thumb keys.
+	# Thumb keys (all devices).
 	17: "up",
 	18: "left",
 	19: "right",
 	20: "down",
+	# Joystick (Brailliant BI 14).
+	21: "stickUp",
+	22: "stickDown",
+	23: "stickLeft",
+	24: "stickRight",
+	25: "stickAction",
+	# BrailleNote Touch calibration key events.
+	30: "calibrationOk",
+	31: "calibrationFail",
+	32: "calibrationEmpty",
+	34: "calibrationReset",
 }
 FIRST_ROUTING_KEY = 80
 DOT1_KEY = 2
 DOT8_KEY = 9
 SPACE_KEY = 10
 
+USB_IDS_HID = {
+	"VID_1C71&PID_C006", # Brailliant BI 32, 40 and 80
+	"VID_1C71&PID_C022", # Brailliant BI 14
+	"VID_1C71&PID_C00A", # BrailleNote Touch
+}
+USB_IDS_SER = (
+	"Vid_1c71&Pid_c005", # Brailliant BI 32, 40 and 80
+	"Vid_1c71&Pid_c021", # Brailliant BI 14
+)
+
 def _getPorts():
-	# USB HID.
+	# HID.
 	for portInfo in hwPortUtils.listHidDevices():
-		if portInfo.get("usbID") == "VID_1C71&PID_C006":
+		if portInfo.get("usbID") in USB_IDS_HID:
 			yield "USB HID", portInfo["devicePath"]
+		# In Windows 10, the Bluetooth vendor and product ids don't get recognised.
+		# Use strings instead.
+		elif portInfo.get("manufacturer") == "Humanware" and portInfo.get("product") == "Brailliant HID":
+			yield "Bluetooth HID", portInfo["devicePath"]
 
 	# USB serial.
-	try:
-		rootKey = _winreg.OpenKey(_winreg.HKEY_LOCAL_MACHINE, r"SYSTEM\CurrentControlSet\Enum\USB\Vid_1c71&Pid_c005")
-	except WindowsError:
-		# A display has never been connected via USB.
-		pass
-	else:
+	for usbId in USB_IDS_SER:
+		try:
+			rootKey = _winreg.OpenKey(_winreg.HKEY_LOCAL_MACHINE,
+				r"SYSTEM\CurrentControlSet\Enum\USB\%s" % usbId)
+		except WindowsError:
+			# A display with this id has never been connected via USB.
+			continue
 		with rootKey:
 			for index in itertools.count():
 				try:
 					keyName = _winreg.EnumKey(rootKey, index)
 				except WindowsError:
-					break
+					break # No more sub-keys.
 				try:
 					with _winreg.OpenKey(rootKey, os.path.join(keyName, "Device Parameters")) as paramsKey:
 						yield "USB serial", _winreg.QueryValueEx(paramsKey, "PortName")[0]
 				except WindowsError:
 					continue
 
-	# Bluetooth.
+	# Bluetooth serial.
 	for portInfo in hwPortUtils.listComPorts(onlyAvailable=True):
 		try:
 			btName = portInfo["bluetoothName"]
 		except KeyError:
 			continue
-		if btName.startswith("Brailliant B") or btName == "Brailliant 80":
-			yield "bluetooth", portInfo["port"]
+		if btName.startswith("Brailliant B") or btName == "Brailliant 80" or "BrailleNote Touch" in btName:
+			yield "Bluetooth serial", portInfo["port"]
 
 class BrailleDisplayDriver(braille.BrailleDisplayDriver):
 	name = "brailliantB"
 	# Translators: The name of a series of braille displays.
-	description = _("HumanWare Brailliant BI/B series")
+	description = _("HumanWare Brailliant BI/B series / BrailleNote Touch")
 	isThreadSafe = True
 
 	@classmethod
@@ -116,7 +147,7 @@ def __init__(self):
 		self.numCells = 0
 
 		for portType, port in _getPorts():
-			self.isHid = portType == "USB HID"
+			self.isHid = portType.endswith(" HID")
 			# Try talking to the display.
 			try:
 				if self.isHid:
@@ -124,26 +155,22 @@ def __init__(self):
 				else:
 					self._dev = hwIo.Serial(port, baudrate=BAUD_RATE, parity=PARITY, timeout=TIMEOUT, writeTimeout=TIMEOUT, onReceive=self._serOnReceive)
 			except EnvironmentError:
-				continue
-			if self.isHid:
-				data = self._dev.getFeature(HR_CAPS)
-				self.numCells = ord(data[24])
-			else:
-				# This will cause the number of cells to be returned.
-				self._serSendMessage(MSG_INIT)
-				# #5406: With the new USB driver, the first command is ignored after a reconnection.
-				# Send the init message again just in case.
-				self._serSendMessage(MSG_INIT)
-				self._dev.waitForRead(TIMEOUT)
-				if not self.numCells:
-					# HACK: When connected via bluetooth, the display sometimes reports communication not allowed on the first attempt.
-					self._serSendMessage(MSG_INIT)
-					self._dev.waitForRead(TIMEOUT)
+				continue # Couldn't connect.
+			# The Brailliant can fail to init if you try immediately after connecting.
+			time.sleep(DELAY_AFTER_CONNECT)
+			# Sometimes, a few attempts are needed to init successfully.
+			for attempt in xrange(INIT_ATTEMPTS):
+				if attempt > 0: # Not the first attempt
+					time.sleep(INIT_RETRY_DELAY) # Delay before next attempt.
+				self._initAttempt()
+				if self.numCells:
+					break # Success!
 			if self.numCells:
 				# A display responded.
 				log.info("Found display with {cells} cells connected via {type} ({port})".format(
 					cells=self.numCells, type=portType, port=port))
 				break
+			# This device can't be initialized. Move on to the next (if any).
 			self._dev.close()
 
 		else:
@@ -152,6 +179,19 @@ def __init__(self):
 		self._keysDown = set()
 		self._ignoreKeyReleases = False
 
+	def _initAttempt(self):
+		if self.isHid:
+			try:
+				data = self._dev.getFeature(HR_CAPS)
+			except WindowsError:
+				return # Fail!
+			self.numCells = ord(data[24])
+		else:
+			# This will cause the display to return the number of cells.
+			# The _serOnReceive callback will see this and set self.numCells.
+			self._serSendMessage(MSG_INIT)
+			self._dev.waitForRead(TIMEOUT)
+
 	def terminate(self):
 		try:
 			super(BrailleDisplayDriver, self).terminate()
@@ -230,10 +270,12 @@ def display(self, cells):
 		# cells will already be padded up to numCells.
 		cells = "".join(chr(cell) for cell in cells)
 		if self.isHid:
-			self._dev.write("{id}"
+			outputReport=("{id}"
 				"\x01\x00" # Module 1, offset 0
 				"{length}{cells}"
 			.format(id=HR_BRAILLE, length=chr(self.numCells), cells=cells))
+			#: Humanware HID devices require the use of HidD_SetOutputReport when sending data to the device via HID, as WriteFile seems to block forever or fail to reach the device at all.
+			self._dev.setOutputReport(outputReport)
 		else:
 			self._serSendMessage(MSG_DISPLAY, cells)
 
@@ -245,19 +287,29 @@ def display(self, cells):
 			"braille_nextLine": ("br(brailliantB):down",),
 			"braille_routeTo": ("br(brailliantB):routing",),
 			"braille_toggleTether": ("br(brailliantB):up+down",),
-			"kb:upArrow": ("br(brailliantB):space+dot1",),
-			"kb:downArrow": ("br(brailliantB):space+dot4",),
-			"kb:leftArrow": ("br(brailliantB):space+dot3",),
-			"kb:rightArrow": ("br(brailliantB):space+dot6",),
-			"showGui": ("br(brailliantB):c1+c3+c4+c5",),
+			"kb:upArrow": ("br(brailliantB):space+dot1", "br(brailliantB):stickUp"),
+			"kb:downArrow": ("br(brailliantB):space+dot4", "br(brailliantB):stickDown"),
+			"kb:leftArrow": ("br(brailliantB):space+dot3", "br(brailliantB):stickLeft"),
+			"kb:rightArrow": ("br(brailliantB):space+dot6", "br(brailliantB):stickRight"),
+			"showGui": (
+				"br(brailliantB):c1+c3+c4+c5",
+				"br(brailliantB):space+dot1+dot3+dot4+dot5",
+			),
 			"kb:shift+tab": ("br(brailliantB):space+dot1+dot3",),
 			"kb:tab": ("br(brailliantB):space+dot4+dot6",),
 			"kb:alt": ("br(brailliantB):space+dot1+dot3+dot4",),
 			"kb:escape": ("br(brailliantB):space+dot1+dot5",),
-			"kb:windows+d": ("br(brailliantB):c1+c4+c5",),
+			"kb:enter": ("br(brailliantB):stickAction"),
+			"kb:windows+d": (
+				"br(brailliantB):c1+c4+c5",
+				"br(brailliantB):Space+dot1+dot4+dot5",
+			),
 			"kb:windows": ("br(brailliantB):space+dot3+dot4",),
 			"kb:alt+tab": ("br(brailliantB):space+dot2+dot3+dot4+dot5",),
-			"sayAll": ("br(brailliantB):c1+c2+c3+c4+c5+c6",),
+			"sayAll": (
+				"br(brailliantB):c1+c2+c3+c4+c5+c6",
+				"br(brailliantB):Space+dot1+dot2+dot3+dot4+dot5+dot6",
+			),
 		},
 	})
 
diff --git a/source/hwIo.py b/source/hwIo.py
--- a/source/hwIo.py
+++ b/source/hwIo.py
@@ -312,6 +312,23 @@ def setFeature(self, report):
 				log.debug("Set feature failed: %s" % ctypes.WinError())
 			raise ctypes.WinError()
 
+	def setOutputReport(self,report):
+		"""
+		Write the given report to the device using HidD_SetOutputReport.
+		This is instead of using the standard WriteFile which may freeze with some USB HID implementations.
+		@param report: The report, including its id.
+		@type report: str
+		"""
+		length=len(report)
+		buf=ctypes.create_string_buffer(length)
+		buf.raw=report
+		if _isDebug():
+			log.debug("Set output report: %r" % report)
+		if not ctypes.windll.hid.HidD_SetOutputReport(self._writeFile,buf,length):
+			if _isDebug():
+				log.debug("Set output report failed: %s" % ctypes.WinError())
+			raise ctypes.WinError()
+
 	def close(self):
 		if not self._file:
 			return
diff --git a/source/hwPortUtils.py b/source/hwPortUtils.py
--- a/source/hwPortUtils.py
+++ b/source/hwPortUtils.py
@@ -8,8 +8,9 @@
 
 import itertools
 import ctypes
-from ctypes.wintypes import BOOL, WCHAR, HWND, DWORD, ULONG, WORD
+from ctypes.wintypes import BOOL, WCHAR, HWND, DWORD, ULONG, WORD, USHORT
 import _winreg as winreg
+import winKernel
 from winKernel import SYSTEMTIME
 import config
 from logHandler import log
@@ -400,6 +401,58 @@ def __str__(self):
 	if _isDebug():
 		log.debug("Finished listing USB devices")
 
+class HIDD_ATTRIBUTES(ctypes.Structure):
+	_fields_ = (
+		("Size", ULONG),
+		("VendorID", USHORT),
+		("ProductID", USHORT),
+		("VersionNumber", USHORT)
+	)
+	def __init__(self, **kwargs):
+		super(HIDD_ATTRIBUTES, self).__init__(Size=ctypes.sizeof(HIDD_ATTRIBUTES), **kwargs)
+
+def _getHidInfo(hwId, path):
+	info = {
+		"hardwareID": hwId,
+		"devicePath": path}
+	hwId = hwId.split("\\", 1)[1]
+	if hwId.startswith("VID"):
+		info["provider"] = "usb"
+		info["usbID"] = hwId[:17] # VID_xxxx&PID_xxxx
+		return info
+	if not hwId.startswith("{00001124-0000-1000-8000-00805f9b34fb}"): # Not Bluetooth
+		# Unknown provider.
+		info["provider"] = None
+		return info
+	info["provider"] = "bluetooth"
+	# Fetch additional info about the HID device.
+	# This is a bit slow, so we only do it for Bluetooth,
+	# as this info might be necessary to identify Bluetooth devices.
+	from serial.win32 import CreateFile, INVALID_HANDLE_VALUE, FILE_FLAG_OVERLAPPED
+	handle = CreateFile(path, 0,
+		winKernel.FILE_SHARE_READ | winKernel.FILE_SHARE_WRITE, None,
+		winKernel.OPEN_EXISTING, FILE_FLAG_OVERLAPPED, None)
+	if handle == INVALID_HANDLE_VALUE:
+		if _isDebug():
+			log.debug(u"Opening device {dev} to get additional info failed: {exc}".format(
+				dev=path, exc=ctypes.WinError()))
+		return info
+	try:
+		attribs = HIDD_ATTRIBUTES()
+		if ctypes.windll.hid.HidD_GetAttributes(handle, ctypes.byref(attribs)):
+			info["vendorID"] = attribs.VendorID
+			info["productID"] = attribs.ProductID
+			info["versionNumber"] = attribs.VersionNumber
+		buf = ctypes.create_unicode_buffer(128)
+		bytes = ctypes.sizeof(buf)
+		if ctypes.windll.hid.HidD_GetManufacturerString(handle, buf, bytes):
+			info["manufacturer"] = buf.value
+		if ctypes.windll.hid.HidD_GetProductString(handle, buf, bytes):
+			info["product"] = buf.value
+	finally:
+		winKernel.closeHandle(handle)
+	return info
+
 _hidGuid = None
 def listHidDevices(onlyAvailable=True):
 	"""List HID devices on the system.
@@ -482,12 +535,7 @@ def __str__(self):
 					raise ctypes.WinError()
 			else:
 				hwId = buf.value
-				info = {
-					"hardwareID": hwId,
-					"devicePath": idd.DevicePath}
-				hwId = hwId.split("\\", 1)[1]
-				if hwId.startswith("VID"):
-					info["usbID"] = hwId[:17] # VID_xxxx&PID_xxxx
+				info = _getHidInfo(hwId, idd.DevicePath)
 				if _isDebug():
 					log.debug("%r" % info)
 				yield info


Test patch: