Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions source/brailleDisplayDrivers/dotPad/defs.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ class DP_Command(enum.IntEnum):
NTF_KEYS_FUNCTION = 0x0332
NTF_ERROR = 0x9902

@property
def secondByte(self) -> int:
"""Get the second byte (LSB) of the command.

DotPad protocol uses big-endian encoding for 2-byte commands.
For most commands: second byte indicates message type (REQ=x0, RSP=x1, NTF=x2).
For key commands (0x03xx): second byte indicates key group (0x02/0x12/0x22/0x32).

Example: NTF_KEYS_FUNCTION (0x0332) -> 0x32
"""
return self.value & 0xFF


class DP_ErrorCode(enum.IntEnum):
LENGTH = 1
Expand Down Expand Up @@ -105,4 +117,16 @@ class DP_PerkinsKey(enum.IntEnum):
NAV_LEFT = 20


class DP_KeyGroup(enum.IntEnum):
"""Key groups for DotPad notifications.

Values correspond to the second byte of NTF_KEYS_* commands.
"""

SCROLL = 0x02 # From NTF_KEYS_SCROLL (0x0302)
PERKINS = 0x12 # From NTF_KEYS_PERKINS (0x0312)
ROUTING = 0x22 # From NTF_KEYS_ROUTING (0x0322)
FUNCTION = 0x32 # From NTF_KEYS_FUNCTION (0x0332)


DP_CHECKSUM_BASE = 0xA5
158 changes: 129 additions & 29 deletions source/brailleDisplayDrivers/dotPad/driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import functools
import operator
import enum
import time
from dataclasses import dataclass
import serial
import inputCore
Expand All @@ -29,9 +30,14 @@
DP_PerkinsKey,
DP_BoardInformation,
DP_CHECKSUM_BASE,
DP_KeyGroup,
)


# Long press threshold in seconds
LONG_PRESS_THRESHOLD: float = 1.5
Copy link
Member

Choose a reason for hiding this comment

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

I think this needs to be configurable, otherwise people with varying dexterity abilities cannot adjust it adequately. For example, some people would be long pressing every key, and high dexterity people might want to lower it. Related WCAG.
I think we would want a setting like multi press timeout

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I agree, but will wait with implementing something until we decided if this should be generic. If not, it will probably become a driver setting.

Btw, the 1.5 sec is chosen since that seems to match the time required to trigger battery status using panLeft+panRight, I did not consult any literature on what would be an optimal long press time for most users.



class DpTactileGraphicsBuffer(TactileGraphicsBuffer):
cellWidth = 2
cellHeight = 4
Expand Down Expand Up @@ -213,16 +219,68 @@ def _handleNotification(self, cmd: DP_Command, data: bytes, dest: int = 0, seqNu
if cmd == DP_Command.NTF_KEYS_PERKINS:
log.debug(f"Perkins keys {data}")
if cmd in (DP_Command.NTF_KEYS_FUNCTION, DP_Command.NTF_KEYS_PERKINS):
try:
gesture = DPKeyGesture(self.model, cmd, data)
except ValueError:
return
if inputCore.manager is not None:
# Extract key group from second byte of command
self._handleKeyPress(cmd.secondByte, data)

def _handleKeyPress(self, groupNum: int, data: bytes):
"""Handle a key press notification from the display.

Tracks keys across multiple groups and fires gesture only when all keys released.
Supports multi-button combinations and long press detection.

The bit order is reversed (LSB to MSB) to match BRLTTY key numbering scheme.

:param groupNum: The key group ID (second byte of notification command)
:param data: The key press data as bytes
"""
try:
group = DP_KeyGroup(groupNum)
except ValueError:
log.debugWarning(f"Unknown key group: {groupNum}")
return

# Check if any key in this group is pressed
anyKeyPressed = any(byte != 0 for byte in data)

if anyKeyPressed:
# At least one key in this group is pressed
# Track first key press time if this is the start of a new gesture
if not self._keysPressed:
self._firstKeyPressTime = time.time()

# Extract which keys are pressed
for byteIndex, byte in enumerate(data):
for bitPos in range(8):
# Reverse bit order (check bit 7-bitPos) to match BRLTTY key numbering
if byte & (1 << (7 - bitPos)):
keyNumber = byteIndex * 8 + bitPos
self._keysPressed.add((group, keyNumber))

# Mark this group as having pressed keys
self._keyGroupsReleased[group] = False
else:
# All keys in this group have been released
self._keyGroupsReleased[group] = True

# Check if all groups are now released
if self._keysPressed and all(self._keyGroupsReleased.values()):
# All key groups released - fire the gesture
isLongPress = False
if self._firstKeyPressTime is not None:
elapsedTime = time.time() - self._firstKeyPressTime
isLongPress = elapsedTime >= LONG_PRESS_THRESHOLD

try:
inputCore.manager.executeGesture(gesture)
except inputCore.NoInputGestureAction:
gesture = DPInputGesture(self.model, self._keysPressed, isLongPress)
if inputCore.manager is not None:
inputCore.manager.executeGesture(gesture)
except (ValueError, inputCore.NoInputGestureAction):
pass

# Reset state for next gesture
self._keysPressed.clear()
self._firstKeyPressTime = None

def __init__(self, port: str = "auto"):
if port == "auto":
# Try autodetection
Expand All @@ -238,6 +296,15 @@ def __init__(self, port: str = "auto"):

super().__init__()

# Key press tracking for multi-button combinations and long press detection
self._keysPressed: set[tuple[int, int]] = set()
self._keyGroupsReleased: dict[int, bool] = {}
self._firstKeyPressTime: float | None = None

# Initialize all key groups as released
for group in DP_KeyGroup:
self._keyGroupsReleased[group] = True

def _tryConnect(self, port: str) -> bool:
"""Try to connect to a DotPad device on the given port.

Expand Down Expand Up @@ -399,33 +466,66 @@ def display(self, cells: list[int]):
gestureMap = inputCore.GlobalGestureMap(
{
"globalCommands.GlobalCommands": {
"braille_scrollBack": ("br(dotPad):pan_left",),
"braille_scrollForward": ("br(dotPad):pan_right",),
"braille_scrollBack": ("br(dotPad):panLeft",),
"braille_scrollForward": ("br(dotPad):panRight",),
},
},
)


class DPKeyGesture(braille.BrailleDisplayGesture):
class DPInputGesture(braille.BrailleDisplayGesture):
"""Input gesture for DotPad display supporting multi-button combinations and long press."""

source = BrailleDisplayDriver.name

def __init__(self, model: str, cmd: DP_Command, data: bytes):
def __init__(self, model: str, keys: set[tuple[int, int]], isLongPress: bool = False):
"""Initialize gesture from pressed keys.

:param model: The device model name (e.g., "DotPad320A") for model-specific gestures
:param keys: Set of (group, keyNumber) tuples representing pressed keys
:param isLongPress: Whether this gesture is a long press (held >= 1.5 seconds)
:raises ValueError: If no valid keys can be mapped to names
"""
super().__init__()
self.model = model
if cmd == DP_Command.NTF_KEYS_FUNCTION:
functionNum = 0
for dataByte in data:
for bit in range(7, -1, -1):
functionNum += 1
if dataByte & 1 << bit:
self.id = f"function{functionNum}"
return
else:
raise ValueError("No function key")
elif cmd == DP_Command.NTF_KEYS_PERKINS:
for key in DP_PerkinsKey:
dataIndex, bitIndex = divmod(key.value, 8)
bitIndex = 7 - bitIndex
if data[dataIndex] & 1 << bitIndex:
self.id = key.name.lower()
return
raise ValueError(f"Unsupported command {cmd.name}")
self.keys = keys
self.isLongPress = isLongPress
self.keyNames = []

# Build key names from all pressed keys
for group, keyNumber in sorted(keys):
if group == DP_KeyGroup.FUNCTION:
self.keyNames.append(f"f{keyNumber + 1}")
elif group == DP_KeyGroup.PERKINS:
try:
# Map to DP_PerkinsKey enum and convert to lowercase
perkinsKey = DP_PerkinsKey(keyNumber)
# Convert SCREAMING_SNAKE_CASE to camelCase for gesture IDs
keyName = self._formatPerkinsKeyName(perkinsKey.name)
self.keyNames.append(keyName)
except ValueError:
log.warning(f"Unknown Perkins key: {keyNumber}")
# TODO: Add support for ROUTING and SCROLL groups when needed

if not self.keyNames:
raise ValueError("No valid key names generated from pressed keys")

# Build gesture ID
baseId = "+".join(self.keyNames)
self.id = f"longPress({baseId})" if isLongPress else baseId
Copy link
Member

Choose a reason for hiding this comment

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

I think we need to add longpress spec information to InputGesture and the developer guide

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, but here I will also wait until we know if this will be generic.


def _formatPerkinsKeyName(self, enumName: str) -> str:
"""Convert DP_PerkinsKey enum name to gesture-friendly format.

Converts SCREAMING_SNAKE_CASE like 'PAN_LEFT' to camelCase like 'panLeft'.
Single words like 'SPACE' become lowercase like 'space'.

:param enumName: The enum member name (e.g., 'PAN_LEFT', 'SPACE', 'DOT7')
:return: Formatted key name for gesture ID
"""
parts = enumName.split("_")
if len(parts) == 1:
# Single word: just lowercase (e.g., SPACE -> space, DOT7 -> dot7)
return parts[0].lower()
# Multiple words: camelCase (e.g., PAN_LEFT -> panLeft)
return parts[0].lower() + "".join(word.capitalize() for word in parts[1:])
4 changes: 4 additions & 0 deletions user_docs/en/changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
* Added an unassigned Quick Navigation Command for jumping to next/previous slider in browse mode. (#17005, @hdzrvcc0X74)
* New types have been added for Speech Dictionary entries, such as part of word and start of word.
Consult the speech dictionaries section in the User Guide for more details. (#19506, @LeonarddeR)
* DotPad braille displays now support multi-button combinations and long press gestures. (#19565, @bramd)
* You can now press multiple buttons simultaneously and assign functions to them in NVDA's Input Gestures (e.g., `f1+panLeft`).
* Long press support allows holding buttons for 1.5 seconds to trigger alternative actions (e.g., `longPress(panLeft)`).
* Note: The firmware feature that presents battery status when long pressing `panLeft+panRight` will not trigger NVDA commands assigned to the `panLeft+panRight` (short press) gesture combination.

### Changes

Expand Down
16 changes: 13 additions & 3 deletions user_docs/en/userGuide.md
Original file line number Diff line number Diff line change
Expand Up @@ -6033,7 +6033,17 @@ The A300 model has a tactile graphics area of 120 by 80 dots, which can fit 8 li

You can configure whether NVDA displays braille on the dedicated braille display line or on the tactile graphics area via the Braille Destination option in NVDA's Braille settings for this driver.

Panning keys are supported, but due to limited buttons on the device, other commands and routing capabilities are currently not available.
Due to limited buttons on the device, only panning is mapped by default.
However, the display supports multi-button combinations and long press gestures, allowing you to assign custom functions via NVDA's Input Gestures dialog.
To create a multi-button combination, press multiple keys simultaneously (e.g., `f1+panLeft`).
For long press gestures, hold buttons for 1.5 seconds to trigger an alternative action (e.g., `longPress(panLeft)`).

Available keys on current hardware include:

* Pan keys: `panLeft`, `panRight`
* Function keys: `f1` through `f4`

Note: The firmware feature that presents battery status when long pressing `panLeft+panRight` will not trigger NVDA commands assigned to the `panLeft+panRight` (short press) gesture combination.

The Dot Pad driver supports automatic detection of USB-connected devices.
However, automatic detection is disabled by default due to the device using generic USB identifiers that could conflict with other devices.
Expand All @@ -6048,8 +6058,8 @@ Make sure to lift your hand entirely off the device when navigating with NVDA, a

| Name |Key|
|---|---|
|Scroll braille display back | `pan_left` |
|Scroll braille display forward | `pan_right` |
|Scroll braille display back | `panLeft` |
|Scroll braille display forward | `panRight` |

<!-- KC:endInclude -->

Expand Down
Loading