diff --git a/source/brailleDisplayDrivers/nlseReaderZoomax.py b/source/brailleDisplayDrivers/nlseReaderZoomax.py new file mode 100644 index 00000000000..eb13448227a --- /dev/null +++ b/source/brailleDisplayDrivers/nlseReaderZoomax.py @@ -0,0 +1,296 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2025 NV Access Limited, Zoomax +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. +# NLS eReader Zoomax driver for NVDA. + +import time +import braille +import inputCore +from logHandler import log +import brailleInput +import hwIo +import bdDetect +import serial + +TIMEOUT_SEC = 0.2 +BAUD_RATE = 19200 +CONNECT_RETRIES = 5 +TIMEOUT_BETWEEN_RETRIES_SEC = 2 + +ESCAPE = b"\x1b" + +LOC_DISPLAY_DATA = b"\x01" +LOC_REQUEST_INFO = b"\x02" +LOC_REQUEST_VERSION = b"\x05" +LOC_REPEAT_ALL = b"\x08" +LOC_PROTOCOL_ONOFF = b"\x15" +LOC_ROUTING_KEYS = b"\x22" +LOC_DISPLAY_KEYS = b"\x24" +LOC_ROUTING_KEY = b"\x27" +LOC_BRAILLE_KEYS = b"\x33" +LOC_JOYSTICK_KEYS = b"\x34" +LOC_DEVICE_ID = b"\x84" +LOC_SERIAL_NUMBER = b"\x8a" + +LOC_RSP_LENGTHS = { + LOC_DISPLAY_DATA: 1, + LOC_DISPLAY_KEYS: 1, + LOC_ROUTING_KEY: 1, + LOC_BRAILLE_KEYS: 2, + LOC_JOYSTICK_KEYS: 1, + LOC_DEVICE_ID: 16, + LOC_SERIAL_NUMBER: 8, +} + +KEY_NAMES = { + LOC_ROUTING_KEYS: None, + LOC_ROUTING_KEY: None, + LOC_DISPLAY_KEYS: ("d1", "d2", "d3", "d4", "d5", "d6"), + LOC_BRAILLE_KEYS: ( + "bl", + "br", + "bs", + None, + "s1", + "s2", + "s3", + "s4", # byte 1 + "b1", + "b2", + "b3", + "b4", + "b5", + "b6", + "b7", + "b8", # byte 2 + ), + LOC_JOYSTICK_KEYS: ("up", "left", "down", "right", "select"), +} + + +class BrailleDisplayDriver(braille.BrailleDisplayDriver): + _dev: hwIo.IoBase + name = "nlseReaderZoomax" + # Translators: Names of braille displays. + description = _("NLS eReader Zoomax") + isThreadSafe = True + supportsAutomaticDetection = True + + @classmethod + def registerAutomaticDetection(cls, driverRegistrar: bdDetect.DriverRegistrar): + driverRegistrar.addUsbDevices( + bdDetect.DeviceType.SERIAL, + { + "VID_1A86&PID_7523", # CH340 + }, + ) + + driverRegistrar.addBluetoothDevices(lambda m: m.id.startswith("NLS eReader Z")) + + @classmethod + def getManualPorts(cls): + return braille.getSerialPorts() + + def _connect(self, port): + for portType, portId, port, portInfo in self._getTryPorts(port): + try: + self._dev = hwIo.Serial( + port, + baudrate=BAUD_RATE, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + timeout=TIMEOUT_SEC, + writeTimeout=TIMEOUT_SEC, + onReceive=self._onReceive, + ) + except EnvironmentError: + log.info("Port not yet available.") + log.debugWarning("", exc_info=True) + if self._dev: + self._dev.close() + continue + + self._sendRequest(LOC_PROTOCOL_ONOFF, False) + self._sendRequest(LOC_PROTOCOL_ONOFF, True) + self._sendRequest(LOC_PROTOCOL_ONOFF, True) + self._sendRequest(LOC_REPEAT_ALL) + + for i in range(5): + self._dev.waitForRead(TIMEOUT_SEC) + if self.numCells: + break + + if self.numCells: + log.info( + "Device connected via {type} ({port})".format( + type=portType, + port=port, + ), + ) + return True + log.info("Device arrival timeout") + self._dev.close() + return False + + def __init__(self, port="auto"): + log.info("nlseReaderZoomax Init") + super(BrailleDisplayDriver, self).__init__() + self.numCells = 0 + self._deviceID: str | None = None + self._dev = None + + for i in range(CONNECT_RETRIES): + if self._connect(port): + break + else: + time.sleep(TIMEOUT_BETWEEN_RETRIES_SEC) + + self._keysDown = {} + self._ignoreKeyReleases = False + + def terminate(self): + try: + super(BrailleDisplayDriver, self).terminate() + try: + self._sendRequest(LOC_PROTOCOL_ONOFF, False) + except EnvironmentError: + pass + finally: + self._dev.close() + + def _sendRequest(self, command: bytes, arg: bytes | bool | int = b""): + """ + :type command: bytes + :type arg: bytes | bool | int + """ + typeErrorString = "Expected param '{}' to be of type '{}', got '{}'" + if not isinstance(arg, bytes): + if isinstance(arg, bool): + arg = hwIo.boolToByte(arg) + elif isinstance(arg, int): + arg = hwIo.intToByte(arg) + else: + raise TypeError(typeErrorString.format("arg", "bytes, bool, or int", type(arg).__name__)) + + if not isinstance(command, bytes): + raise TypeError(typeErrorString.format("command", "bytes", type(command).__name__)) + + # doubling the escape characters in the data (arg) part + # as requried by the device communication protocol + arg = arg.replace(ESCAPE, ESCAPE * 2) + + data = b"".join( + [ + ESCAPE, + command, + arg, + ], + ) + self._dev.write(data) + + def _onReceive(self, data: bytes): + if data != ESCAPE: + log.debugWarning(f"Ignoring byte before escape: {data!r}") + return + # data only contained the escape. Read the rest from the device. + stream = self._dev + command = stream.read(1) + length = LOC_RSP_LENGTHS.get(command, 0) + if command == LOC_ROUTING_KEYS: + length = 10 if self.numCells > 40 else 5 + arg = stream.read(length) + self._handleResponse(command, arg) + + def _handleResponse(self, command: bytes, arg: bytes): + if command == LOC_DISPLAY_DATA: + self.numCells = ord(arg) + elif command == LOC_DEVICE_ID: + # Short ids can be padded with either nulls or spaces. + arg = arg.rstrip(b"\0 ") + # Assumption: all device IDs can be decoded with latin-1. + # If not, we wish to know about it, allow decode to raise. + self._deviceID = arg.decode("latin-1", errors="strict") + elif command in KEY_NAMES: + arg = int.from_bytes(reversed(arg)) + if arg < self._keysDown.get(command, 0): + # Release. + if not self._ignoreKeyReleases: + # The first key released executes the key combination. + try: + inputCore.manager.executeGesture(InputGesture(self._deviceID, self._keysDown)) + except inputCore.NoInputGestureAction: + pass + # Any further releases are just the rest of the keys in the combination being released, + # so they should be ignored. + self._ignoreKeyReleases = True + else: + # Press. + # This begins a new key combination. + self._ignoreKeyReleases = False + if arg > 0: + self._keysDown[command] = arg + elif command in self._keysDown: + # All keys in this group have been released. + # #3541: Remove this group so it doesn't count as a group with keys down. + del self._keysDown[command] + else: + log.debugWarning("Unknown command {command!r}, arg {arg!r}".format(command=command, arg=arg)) + + def display(self, cells: list[int]): + # cells will already be padded up to numCells. + arg = bytes(cells) + self._sendRequest(LOC_DISPLAY_DATA, arg) + + gestureMap = inputCore.GlobalGestureMap( + { + "globalCommands.GlobalCommands": { + "braille_scrollBack": ("br(nlseReaderZoomax):d2",), + "braille_scrollForward": ("br(nlseReaderZoomax):d5",), + "braille_previousLine": ("br(nlseReaderZoomax):d1",), + "braille_nextLine": ("br(nlseReaderZoomax):d3",), + "braille_routeTo": ("br(nlseReaderZoomax):routing",), + "kb:upArrow": ("br(nlseReaderZoomax):up",), + "kb:downArrow": ("br(nlseReaderZoomax):down",), + "kb:leftArrow": ("br(nlseReaderZoomax):left",), + "kb:rightArrow": ("br(nlseReaderZoomax):right",), + "kb:enter": ("br(nlseReaderZoomax):select",), + }, + }, + ) + + +class InputGesture(braille.BrailleDisplayGesture, brailleInput.BrailleInputGesture): + source = BrailleDisplayDriver.name + + def __init__(self, model, keysDown): + super(InputGesture, self).__init__() + # Model identifiers should not contain spaces. + if model: + self.model = model.replace(" ", "") + assert self.model.isalnum() + self.keysDown = dict(keysDown) + + self.keyNames = names = [] + for group, groupKeysDown in keysDown.items(): + if group == LOC_BRAILLE_KEYS and len(keysDown) == 1 and not groupKeysDown & 0xF8: + # This is braille input. + # 0xF8 covers command keys. The space bars are covered by 0x7. + self.dots = groupKeysDown >> 8 + self.space = groupKeysDown & 0x7 + if group == LOC_ROUTING_KEYS: + for index in range(braille.handler.display.numCells): + if groupKeysDown & (1 << index): + self.routingIndex = index + names.append("routing") + break + elif group == LOC_ROUTING_KEY: + self.routingIndex = groupKeysDown - 1 + names.append("routing") + else: + for index, name in enumerate(KEY_NAMES[group]): + if groupKeysDown & (1 << index): + names.append(name) + + self.id = "+".join(names) diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index cdf5fe1d541..65db0862b86 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -27,6 +27,7 @@ To use this feature, "allow NVDA to control the volume of other applications" mu * Automatic language switching is now supported when using Microsoft Speech API version 5 (SAPI5) and Microsoft Speech Platform voices. (#17146, @gexgd0419) * NVDA can now be configured to speak the current line or paragraph when navigating with braille navigation keys. (#17053, @nvdaes) * In Word, the selection update is now reported when using Word commands to extend or reduce the selection (`f8` or `shift+f8`). (#3293, @CyrilleB79) +* Support for the NLS eReader Zoomax braille display has been added. (#17509, @florin-trutiu) ### Changes diff --git a/user_docs/en/userGuide.md b/user_docs/en/userGuide.md index cd96397c5ee..1675337fc78 100644 --- a/user_docs/en/userGuide.md +++ b/user_docs/en/userGuide.md @@ -3970,6 +3970,7 @@ The following displays support this automatic detection functionality. * Nattiq nBraille displays * Seika Notetaker: MiniSeika (16, 24 cells), V6, and V6Pro (40 cells) * Tivomatic Caiku Albatross 46/80 displays +* NLS eReader Zoomax * Any Display that supports the Standard HID Braille protocol ### Freedom Scientific Focus/PAC Mate Series {#FreedomScientificFocus} @@ -4282,8 +4283,7 @@ The following extra devices are also supported (and do not require any special d * APH Mantis Q40 * APH Chameleon 20 * Humanware BrailleOne -* NLS eReader - * Note that the Zoomax is currently not supported without external drivers +* NLS eReader Humanware Following are the key assignments for the Brailliant BI/B and BrailleNote touch displays with NVDA. Please see the display's documentation for descriptions of where these keys can be found. @@ -5146,6 +5146,34 @@ Please see the display's documentation for descriptions of where these keys can +### NLS eReader Zoomax {#Zoomax} + +The NLS eReader Zoomax device supports USB or bluetooth connections. +The Windows 10 and Windows 11 operating systems will automatically detect and install the necessary drivers for this display. +For computers where the Internet connection is disabled or not available, you can manually [download and install the USB to serial CH340 chip driver](https://www.wch-ic.com/downloads/CH341SER_EXE.html) to support this display over USB. + +By default, NVDA can automatically detect and connect to this display via USB or bluetooth. +However, when configuring the display, you can also explicitly select "USB" or "Bluetooth" ports to restrict the connection type to be used. + +Following are the key assignments for this display with NVDA. +Please see the display's documentation for descriptions of where these keys can be found. + + +| Name |Key| +|---|---| +|Scroll braille display back |`d2`| +|Scroll braille display forward |`d5`| +|Move braille display to previous line |`d1`| +|Move braille display to next line |`d3`| +|Route to braille cell |`routing`| +|Up arrow key |`up`| +|Down arrow key |`down`| +|Left arrow key |`left`| +|Right arrow key |`right`| +|Enter key |`select`| + + + ### Standard HID Braille displays {#HIDBraille} This is an experimental driver for the new Standard HID Braille Specification, agreed upon in 2018 by Microsoft, Google, Apple and several assistive technology companies including NV Access.