Skip to content

Commit dd1070d

Browse files
committed
DotPad: Add multi-button combinations and long press support
This enables complex gestures with multiple buttons pressed simultaneously and detection of long presses (1.5 second threshold). Key changes: - Add DP_KeyGroup enum to identify key groups (FUNCTION, PERKINS, etc.) - Track pressed keys across multiple groups with state management - Fire gestures only when all keys released (enables combinations) - Detect long presses with time-based threshold - Replace DPKeyGesture with enhanced DPInputGesture class - Support gesture IDs like "f1+panLeft" and "longPress(panLeft)" - Update gesture map to use camelCase (panLeft/panRight)
1 parent 1176ef0 commit dd1070d

File tree

3 files changed

+157
-29
lines changed

3 files changed

+157
-29
lines changed

source/brailleDisplayDrivers/dotPad/defs.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,18 @@ class DP_Command(enum.IntEnum):
2727
NTF_KEYS_FUNCTION = 0x0332
2828
NTF_ERROR = 0x9902
2929

30+
@property
31+
def secondByte(self) -> int:
32+
"""Get the second byte (LSB) of the command.
33+
34+
DotPad protocol uses big-endian encoding for 2-byte commands.
35+
For most commands: second byte indicates message type (REQ=x0, RSP=x1, NTF=x2).
36+
For key commands (0x03xx): second byte indicates key group (0x02/0x12/0x22/0x32).
37+
38+
Example: NTF_KEYS_FUNCTION (0x0332) -> 0x32
39+
"""
40+
return self.value & 0xFF
41+
3042

3143
class DP_ErrorCode(enum.IntEnum):
3244
LENGTH = 1
@@ -105,4 +117,16 @@ class DP_PerkinsKey(enum.IntEnum):
105117
NAV_LEFT = 20
106118

107119

120+
class DP_KeyGroup(enum.IntEnum):
121+
"""Key groups for DotPad notifications.
122+
123+
Values correspond to the second byte of NTF_KEYS_* commands.
124+
"""
125+
126+
SCROLL = 0x02 # From NTF_KEYS_SCROLL (0x0302)
127+
PERKINS = 0x12 # From NTF_KEYS_PERKINS (0x0312)
128+
ROUTING = 0x22 # From NTF_KEYS_ROUTING (0x0322)
129+
FUNCTION = 0x32 # From NTF_KEYS_FUNCTION (0x0332)
130+
131+
108132
DP_CHECKSUM_BASE = 0xA5

source/brailleDisplayDrivers/dotPad/driver.py

Lines changed: 129 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import functools
99
import operator
1010
import enum
11+
import time
1112
from dataclasses import dataclass
1213
import serial
1314
import inputCore
@@ -29,9 +30,14 @@
2930
DP_PerkinsKey,
3031
DP_BoardInformation,
3132
DP_CHECKSUM_BASE,
33+
DP_KeyGroup,
3234
)
3335

3436

37+
# Long press threshold in seconds
38+
LONG_PRESS_THRESHOLD: float = 1.5
39+
40+
3541
class DpTactileGraphicsBuffer(TactileGraphicsBuffer):
3642
cellWidth = 2
3743
cellHeight = 4
@@ -213,16 +219,68 @@ def _handleNotification(self, cmd: DP_Command, data: bytes, dest: int = 0, seqNu
213219
if cmd == DP_Command.NTF_KEYS_PERKINS:
214220
log.debug(f"Perkins keys {data}")
215221
if cmd in (DP_Command.NTF_KEYS_FUNCTION, DP_Command.NTF_KEYS_PERKINS):
216-
try:
217-
gesture = DPKeyGesture(self.model, cmd, data)
218-
except ValueError:
219-
return
220-
if inputCore.manager is not None:
222+
# Extract key group from second byte of command
223+
self._handleKeyPress(cmd.secondByte, data)
224+
225+
def _handleKeyPress(self, groupNum: int, data: bytes):
226+
"""Handle a key press notification from the display.
227+
228+
Tracks keys across multiple groups and fires gesture only when all keys released.
229+
Supports multi-button combinations and long press detection.
230+
231+
The bit order is reversed (LSB to MSB) to match BRLTTY key numbering scheme.
232+
233+
:param groupNum: The key group ID (second byte of notification command)
234+
:param data: The key press data as bytes
235+
"""
236+
try:
237+
group = DP_KeyGroup(groupNum)
238+
except ValueError:
239+
log.debugWarning(f"Unknown key group: {groupNum}")
240+
return
241+
242+
# Check if any key in this group is pressed
243+
anyKeyPressed = any(byte != 0 for byte in data)
244+
245+
if anyKeyPressed:
246+
# At least one key in this group is pressed
247+
# Track first key press time if this is the start of a new gesture
248+
if not self._keysPressed:
249+
self._firstKeyPressTime = time.time()
250+
251+
# Extract which keys are pressed
252+
for byteIndex, byte in enumerate(data):
253+
for bitPos in range(8):
254+
# Reverse bit order (check bit 7-bitPos) to match BRLTTY key numbering
255+
if byte & (1 << (7 - bitPos)):
256+
keyNumber = byteIndex * 8 + bitPos
257+
self._keysPressed.add((group, keyNumber))
258+
259+
# Mark this group as having pressed keys
260+
self._keyGroupsReleased[group] = False
261+
else:
262+
# All keys in this group have been released
263+
self._keyGroupsReleased[group] = True
264+
265+
# Check if all groups are now released
266+
if self._keysPressed and all(self._keyGroupsReleased.values()):
267+
# All key groups released - fire the gesture
268+
isLongPress = False
269+
if self._firstKeyPressTime is not None:
270+
elapsedTime = time.time() - self._firstKeyPressTime
271+
isLongPress = elapsedTime >= LONG_PRESS_THRESHOLD
272+
221273
try:
222-
inputCore.manager.executeGesture(gesture)
223-
except inputCore.NoInputGestureAction:
274+
gesture = DPInputGesture(self.model, self._keysPressed, isLongPress)
275+
if inputCore.manager is not None:
276+
inputCore.manager.executeGesture(gesture)
277+
except (ValueError, inputCore.NoInputGestureAction):
224278
pass
225279

280+
# Reset state for next gesture
281+
self._keysPressed.clear()
282+
self._firstKeyPressTime = None
283+
226284
def __init__(self, port: str = "auto"):
227285
if port == "auto":
228286
# Try autodetection
@@ -238,6 +296,15 @@ def __init__(self, port: str = "auto"):
238296

239297
super().__init__()
240298

299+
# Key press tracking for multi-button combinations and long press detection
300+
self._keysPressed: set[tuple[int, int]] = set()
301+
self._keyGroupsReleased: dict[int, bool] = {}
302+
self._firstKeyPressTime: float | None = None
303+
304+
# Initialize all key groups as released
305+
for group in DP_KeyGroup:
306+
self._keyGroupsReleased[group] = True
307+
241308
def _tryConnect(self, port: str) -> bool:
242309
"""Try to connect to a DotPad device on the given port.
243310
@@ -399,33 +466,66 @@ def display(self, cells: list[int]):
399466
gestureMap = inputCore.GlobalGestureMap(
400467
{
401468
"globalCommands.GlobalCommands": {
402-
"braille_scrollBack": ("br(dotPad):pan_left",),
403-
"braille_scrollForward": ("br(dotPad):pan_right",),
469+
"braille_scrollBack": ("br(dotPad):panLeft",),
470+
"braille_scrollForward": ("br(dotPad):panRight",),
404471
},
405472
},
406473
)
407474

408475

409-
class DPKeyGesture(braille.BrailleDisplayGesture):
476+
class DPInputGesture(braille.BrailleDisplayGesture):
477+
"""Input gesture for DotPad display supporting multi-button combinations and long press."""
478+
410479
source = BrailleDisplayDriver.name
411480

412-
def __init__(self, model: str, cmd: DP_Command, data: bytes):
481+
def __init__(self, model: str, keys: set[tuple[int, int]], isLongPress: bool = False):
482+
"""Initialize gesture from pressed keys.
483+
484+
:param model: The device model name (e.g., "DotPad320A") for model-specific gestures
485+
:param keys: Set of (group, keyNumber) tuples representing pressed keys
486+
:param isLongPress: Whether this gesture is a long press (held >= 1.5 seconds)
487+
:raises ValueError: If no valid keys can be mapped to names
488+
"""
489+
super().__init__()
413490
self.model = model
414-
if cmd == DP_Command.NTF_KEYS_FUNCTION:
415-
functionNum = 0
416-
for dataByte in data:
417-
for bit in range(7, -1, -1):
418-
functionNum += 1
419-
if dataByte & 1 << bit:
420-
self.id = f"function{functionNum}"
421-
return
422-
else:
423-
raise ValueError("No function key")
424-
elif cmd == DP_Command.NTF_KEYS_PERKINS:
425-
for key in DP_PerkinsKey:
426-
dataIndex, bitIndex = divmod(key.value, 8)
427-
bitIndex = 7 - bitIndex
428-
if data[dataIndex] & 1 << bitIndex:
429-
self.id = key.name.lower()
430-
return
431-
raise ValueError(f"Unsupported command {cmd.name}")
491+
self.keys = keys
492+
self.isLongPress = isLongPress
493+
self.keyNames = []
494+
495+
# Build key names from all pressed keys
496+
for group, keyNumber in sorted(keys):
497+
if group == DP_KeyGroup.FUNCTION:
498+
self.keyNames.append(f"f{keyNumber + 1}")
499+
elif group == DP_KeyGroup.PERKINS:
500+
try:
501+
# Map to DP_PerkinsKey enum and convert to lowercase
502+
perkinsKey = DP_PerkinsKey(keyNumber)
503+
# Convert SCREAMING_SNAKE_CASE to camelCase for gesture IDs
504+
keyName = self._formatPerkinsKeyName(perkinsKey.name)
505+
self.keyNames.append(keyName)
506+
except ValueError:
507+
log.warning(f"Unknown Perkins key: {keyNumber}")
508+
# TODO: Add support for ROUTING and SCROLL groups when needed
509+
510+
if not self.keyNames:
511+
raise ValueError("No valid key names generated from pressed keys")
512+
513+
# Build gesture ID
514+
baseId = "+".join(self.keyNames)
515+
self.id = f"longPress({baseId})" if isLongPress else baseId
516+
517+
def _formatPerkinsKeyName(self, enumName: str) -> str:
518+
"""Convert DP_PerkinsKey enum name to gesture-friendly format.
519+
520+
Converts SCREAMING_SNAKE_CASE like 'PAN_LEFT' to camelCase like 'panLeft'.
521+
Single words like 'SPACE' become lowercase like 'space'.
522+
523+
:param enumName: The enum member name (e.g., 'PAN_LEFT', 'SPACE', 'DOT7')
524+
:return: Formatted key name for gesture ID
525+
"""
526+
parts = enumName.split("_")
527+
if len(parts) == 1:
528+
# Single word: just lowercase (e.g., SPACE -> space, DOT7 -> dot7)
529+
return parts[0].lower()
530+
# Multiple words: camelCase (e.g., PAN_LEFT -> panLeft)
531+
return parts[0].lower() + "".join(word.capitalize() for word in parts[1:])

user_docs/en/changes.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717
* Added an unassigned Quick Navigation Command for jumping to next/previous slider in browse mode. (#17005, @hdzrvcc0X74)
1818
* New types have been added for Speech Dictionary entries, such as part of word and start of word.
1919
Consult the speech dictionaries section in the User Guide for more details. (#19506, @LeonarddeR)
20+
* DotPad braille displays now support multi-button combinations and long press gestures. (#19565, @bramd)
21+
* You can now press multiple buttons simultaneously and assign functions to them in NVDA's Input Gestures (e.g., `f1+panLeft`).
22+
* Long press support allows holding buttons for 1.5 seconds to trigger alternative actions (e.g., `longPress(panLeft)`).
23+
* 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.
2024

2125
### Changes
2226

0 commit comments

Comments
 (0)