Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NLS eReader Zoomax braille driver #17509

Draft
wants to merge 9 commits into
base: master
Choose a base branch
from
296 changes: 296 additions & 0 deletions source/brailleDisplayDrivers/nlseReaderZoomax.py
Original file line number Diff line number Diff line change
@@ -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"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the point of this?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@SaschaCowley
The nlseReaderZoomax.py driver uses code from the baum.py driver that is already part of the NVDA source code.
This is done so because the NLS eReader Zoomax has a protocol similar with the one used by the Baum Braille displays.
So the plan was to provide a separate driver file but to not change the functionality that was already working and approved by the NVDA team.

Copy link
Author

@florin-trutiu florin-trutiu Jan 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@SaschaCowley I discussed with my manager and I will make also the rest of the changes you requested, but it will take more time as I have a high priority project that needs to be done first.


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"
Comment on lines +23 to +34
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are these? Would they suite being members of an enum? Can they get more descriptive names?


LOC_RSP_LENGTHS = {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this get a type hint, a more descriptive name, and a comment on its purpose?

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 = {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this get a type hint and a comment on its purpose?

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does this comment mean?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@SaschaCowley
CH340 is an USB to serial chip used in the device, and those are it's VID and PID values.

},
)

driverRegistrar.addBluetoothDevices(lambda m: m.id.startswith("NLS eReader Z"))

@classmethod
def getManualPorts(cls):
return braille.getSerialPorts()

def _connect(self, port):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add type hints

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)
Comment on lines +115 to +118
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you comment on what is going on here? In particular, why do you call self._sendRequest(LOC_PROTOCOL_ONOFF, True) twice?


for i in range(5):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why 5? Should this be a constant rather than a literal?

self._dev.waitForRead(TIMEOUT_SEC)
if self.numCells:
break

if self.numCells:
log.info(
"Device connected via {type} ({port})".format(
type=portType,
port=port,
Comment on lines +127 to +129
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be rewritten to use an f-string?

Suggested change
"Device connected via {type} ({port})".format(
type=portType,
port=port,
f"Device connected via {portType} ({port})",

),
)
return True
log.info("Device arrival timeout")
self._dev.close()
return False

def __init__(self, port="auto"):
log.info("nlseReaderZoomax Init")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
log.info("nlseReaderZoomax Init")
log.info("Initializing nlseReaderZoomax driver")

super(BrailleDisplayDriver, self).__init__()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
super(BrailleDisplayDriver, self).__init__()
super().__init__()

self.numCells = 0
self._deviceID: str | None = None
self._dev = None
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This contradicts your earlier type hint


for i in range(CONNECT_RETRIES):
if self._connect(port):
break
else:
time.sleep(TIMEOUT_BETWEEN_RETRIES_SEC)

self._keysDown = {}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a type hint

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()
Comment on lines +154 to +161
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can yoou please explain the nested try blocks?


def _sendRequest(self, command: bytes, arg: bytes | bool | int = b""):
"""
:type command: bytes
:type arg: bytes | bool | int
Comment on lines +165 to +166
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't include type information in docstrings when it's provided in type annotations. Could you explain the purpose and possible values for each argument though?

"""
typeErrorString = "Expected param '{}' to be of type '{}', got '{}'"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you change this to use named rather than positional fields?

Suggested change
typeErrorString = "Expected param '{}' to be of type '{}', got '{}'"
TYPE_ERROR_TEMPLATE = "Expected parameter '{paramName}' to be of type '{expectedType}', got '{actualType}'"

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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please explain why you are doing this?


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]
Comment on lines +217 to +237
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm having a lot of trouble following what's going on here. Could you please comment a bit more on what's going on?

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:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why 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)
1 change: 1 addition & 0 deletions user_docs/en/changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
32 changes: 30 additions & 2 deletions user_docs/en/userGuide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -5146,6 +5146,34 @@ Please see the display's documentation for descriptions of where these keys can

<!-- KC:endInclude -->

### 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.
<!-- KC:beginInclude -->

| 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`|

<!-- KC:endInclude -->

### 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.
Expand Down