diff --git a/source/bdDetect.py b/source/bdDetect.py index 28a32d7929a..03cb71ef710 100644 --- a/source/bdDetect.py +++ b/source/bdDetect.py @@ -12,6 +12,8 @@ For drivers in add-ons, this must be done in a global plugin. """ +from dataclasses import dataclass, field +from functools import partial import itertools import threading from concurrent.futures import ThreadPoolExecutor, Future @@ -51,15 +53,46 @@ USB_ID_REGEX = re.compile(r"^VID_[0-9A-F]{4}&PID_[0-9A-F]{4}$", re.U) -class DeviceType(StrEnum): +class ProtocolType(StrEnum): HID = "hid" """HID devices""" SERIAL = "serial" """Serial devices (COM ports)""" CUSTOM = "custom" - """Devices with a manufacturer specific driver""" + """Devices with a manufacturer specific protocol""" + + +class CommunicationType(StrEnum): BLUETOOTH = "bluetooth" """Bluetooth devices""" + USB = "usb" + """USB devices""" + + +class _DeviceTypeMeta(type): + # Mapping old attributes to the new enums + _mapping = { + "HID": ProtocolType.HID, + "SERIAL": ProtocolType.SERIAL, + "CUSTOM": ProtocolType.CUSTOM, + "BLUETOOTH": CommunicationType.BLUETOOTH, + } + + def __getattr__(cls, name: str) -> ProtocolType | CommunicationType: + repl = cls._mapping.get(name) + if repl is not None and NVDAState._allowDeprecatedAPI(): + log.warning( + f"{cls.__name__}.{name} is deprecated. Use {repl.__class__.__name__}.{repl} instead.", + ) + return repl + raise AttributeError(f"'{cls.__name__}' object has no attribute '{name}'") + + +class DeviceType(metaclass=_DeviceTypeMeta): + """This class is kept for backwards compatibility. + Former members were split into the L{ProtocolType} and L{CommunicationType} enums.""" + + ... def __getattr__(attrName: str) -> Any: @@ -71,25 +104,26 @@ def __getattr__(attrName: str) -> Any: log.warning(f"{attrName} is deprecated.") return 2 _deprecatedConstantsMap = { - "KEY_HID": DeviceType.HID, - "KEY_SERIAL": DeviceType.SERIAL, - "KEY_BLUETOOTH": DeviceType.BLUETOOTH, - "KEY_CUSTOM": DeviceType.CUSTOM, + "KEY_HID": ProtocolType.HID, + "KEY_SERIAL": ProtocolType.SERIAL, + "KEY_BLUETOOTH": CommunicationType.BLUETOOTH, + "KEY_CUSTOM": ProtocolType.CUSTOM, } if attrName in _deprecatedConstantsMap and NVDAState._allowDeprecatedAPI(): replacementSymbol = _deprecatedConstantsMap[attrName] log.warning( - f"{attrName} is deprecated. " f"Use bdDetect.DeviceType.{replacementSymbol.name} instead. ", + f"{attrName} is deprecated. " + f"Use bdDetect.{replacementSymbol.__class__.__name__}.{replacementSymbol.value} instead. ", ) - return replacementSymbol + return replacementSymbol.value raise AttributeError(f"module {repr(__name__)} has no attribute {repr(attrName)}") class DeviceMatch(NamedTuple): """Represents a detected device.""" - type: DeviceType - """The type of the device.""" + type: ProtocolType + """The protocol type of the device.""" id: str """The identifier of the device.""" port: str @@ -99,15 +133,41 @@ class DeviceMatch(NamedTuple): MatchFuncT = Callable[[DeviceMatch], bool] -DriverDictT = defaultdict[DeviceType, set[str] | MatchFuncT] -_driverDevices = OrderedDict[str, DriverDictT]() -fallBackDevices: set[tuple[str, DeviceType, str]] = set() -""" -Used to store fallback devices. -When registered as a fallback device, it will be yielded last among the connected USB devices. -""" +@dataclass(frozen=True) +class _UsbDeviceRegistryEntry: + """An internal class that contains information specific to an USB device registration.""" + + id: str + """The identifier of the device.""" + type: ProtocolType + """The protocol type of the device.""" + useAsFallback: bool = field(default=False, compare=False) + """ + determine how a device is associated with a driver. + If False (default), the device is immediately available for use with the driver. + If True, the device is added to a fallback list and is used only if the primary driver cannot use + initial devices, serving as a backup option in case of compatibility issues. + This provides flexibility and robustness in managing driver-device connections. + """ + matchFunc: MatchFuncT | None = field(default=None, compare=False) + """ + An optional function which determines whether a given device matches. + It takes a L{DeviceMatch} as its only argument + and returns a C{bool} indicating whether it matched.""" + + def matches(self, deviceMatch: DeviceMatch) -> bool: + """Returns whether this registry entry matches a specific device.""" + if deviceMatch.type != self.type or deviceMatch.id != self.id: + return False + if self.matchFunc is None: + return True + return self.matchFunc(deviceMatch) + + +DriverDictT = defaultdict[CommunicationType, set[_UsbDeviceRegistryEntry] | MatchFuncT] +_driverDevices = OrderedDict[str, DriverDictT]() scanForDevices = extensionPoints.Chain[Tuple[str, DeviceMatch]]() """ @@ -139,11 +199,11 @@ def getDriversForConnectedUsbDevices( @return: Generator of pairs of drivers and device information. """ usbCustomDeviceMatches = ( - DeviceMatch(DeviceType.CUSTOM, port["usbID"], port["devicePath"], port) + DeviceMatch(ProtocolType.CUSTOM, port["usbID"], port["devicePath"], port) for port in deviceInfoFetcher.usbDevices ) usbComDeviceMatches = ( - DeviceMatch(DeviceType.SERIAL, port["usbID"], port["port"], port) + DeviceMatch(ProtocolType.SERIAL, port["usbID"], port["port"], port) for port in deviceInfoFetcher.usbComPorts ) # Tee is used to ensure that the DeviceMatches aren't created multiple times. @@ -153,9 +213,9 @@ def getDriversForConnectedUsbDevices( # device matches), if one is found early the iteration can stop. usbHidDeviceMatches, usbHidDeviceMatchesForCustom = itertools.tee( ( - DeviceMatch(DeviceType.HID, port["usbID"], port["devicePath"], port) + DeviceMatch(ProtocolType.HID, port["usbID"], port["devicePath"], port) for port in deviceInfoFetcher.hidDevices - if port["provider"] == "usb" + if port["provider"] == CommunicationType.USB ) ) @@ -164,12 +224,13 @@ def getDriversForConnectedUsbDevices( for driver, devs in _driverDevices.items(): if limitToDevices and driver not in limitToDevices: continue - for type, ids in devs.items(): - if match.type == type and match.id in ids: - if (driver, match.type, match.id) in fallBackDevices: + usbDefinitions = devs[CommunicationType.USB] + for definition in usbDefinitions: + if definition.matches(match): + if definition.useAsFallback: fallbackDriversAndMatches.append({driver, match}) else: - yield driver, match + yield (driver, match) hidName = _getStandardHidDriverName() if limitToDevices and hidName not in limitToDevices: @@ -179,13 +240,10 @@ def getDriversForConnectedUsbDevices( # This ensures that a vendor specific driver is preferred over the braille HID protocol. # This preference may change in the future. if _isHIDBrailleMatch(match): - if (driver, match.type, match.id) in fallBackDevices: - fallbackDriversAndMatches.append({hidName, match}) - else: - yield hidName, match + yield (hidName, match) for driver, match in fallbackDriversAndMatches: - yield driver, match + yield (driver, match) def _getStandardHidDriverName() -> str: @@ -195,8 +253,21 @@ def _getStandardHidDriverName() -> str: return brailleDisplayDrivers.hidBrailleStandard.HidBrailleDriver.name +def _isHIDUsagePageMatch(match: DeviceMatch, usagePage: int) -> bool: + return match.type == ProtocolType.HID and match.deviceInfo.get("HIDUsagePage") == usagePage + + def _isHIDBrailleMatch(match: DeviceMatch) -> bool: - return match.type == DeviceType.HID and match.deviceInfo.get("HIDUsagePage") == HID_USAGE_PAGE_BRAILLE + return _isHIDUsagePageMatch(match, HID_USAGE_PAGE_BRAILLE) + + +def HIDUsagePageMatchFuncFactory(usagePage: int) -> MatchFuncT: + """ + Creates a match function that checks if a given HID usage page matches the specified usage page. + :param usagePage: The HID usage page to match against. + :returns: A partial function that takes an HID usage page and returns True if it matches the specified usage page, False otherwise. + """ + return partial(_isHIDUsagePageMatch, usagePage=usagePage) def getDriversForPossibleBluetoothDevices( @@ -210,7 +281,7 @@ def getDriversForPossibleBluetoothDevices( @return: Generator of pairs of drivers and port information. """ btSerialMatchesForCustom = ( - DeviceMatch(DeviceType.SERIAL, port["bluetoothName"], port["port"], port) + DeviceMatch(ProtocolType.SERIAL, port["bluetoothName"], port["port"], port) for port in deviceInfoFetcher.comPorts if "bluetoothName" in port ) @@ -221,20 +292,20 @@ def getDriversForPossibleBluetoothDevices( # device matches), if one is found early the iteration can stop. btHidDevMatchesForHid, btHidDevMatchesForCustom = itertools.tee( ( - DeviceMatch(DeviceType.HID, port["hardwareID"], port["devicePath"], port) + DeviceMatch(ProtocolType.HID, port["hardwareID"], port["devicePath"], port) for port in deviceInfoFetcher.hidDevices - if port["provider"] == "bluetooth" + if port["provider"] == CommunicationType.BLUETOOTH ) ) for match in itertools.chain(btSerialMatchesForCustom, btHidDevMatchesForCustom): for driver, devs in _driverDevices.items(): if limitToDevices and driver not in limitToDevices: continue - matchFunc = devs[DeviceType.BLUETOOTH] + matchFunc = devs[CommunicationType.BLUETOOTH] if not callable(matchFunc): continue if matchFunc(match): - yield driver, match + yield (driver, match) hidName = _getStandardHidDriverName() if limitToDevices and hidName not in limitToDevices: @@ -467,8 +538,6 @@ def terminate(self): appModuleHandler.post_appSwitch.unregister(self.pollBluetoothDevices) messageWindow.pre_handleWindowMessage.unregister(self.handleWindowMessage) self._stopBgScan() - # Clear the fallback devices - fallBackDevices.clear() # Clear the cache of bluetooth devices so new devices can be picked up with a new instance. deviceInfoFetcher.btDevsCache = None self._executor.shutdown(wait=False) @@ -482,16 +551,16 @@ def getConnectedUsbDevicesForDriver(driver: str) -> Iterator[DeviceMatch]: """ usbDevs = itertools.chain( ( - DeviceMatch(DeviceType.CUSTOM, port["usbID"], port["devicePath"], port) + DeviceMatch(ProtocolType.CUSTOM, port["usbID"], port["devicePath"], port) for port in deviceInfoFetcher.usbDevices ), ( - DeviceMatch(DeviceType.HID, port["usbID"], port["devicePath"], port) + DeviceMatch(ProtocolType.HID, port["usbID"], port["devicePath"], port) for port in deviceInfoFetcher.hidDevices - if port["provider"] == "usb" + if port["provider"] == CommunicationType.USB ), ( - DeviceMatch(DeviceType.SERIAL, port["usbID"], port["port"], port) + DeviceMatch(ProtocolType.SERIAL, port["usbID"], port["port"], port) for port in deviceInfoFetcher.usbComPorts ), ) @@ -501,15 +570,12 @@ def getConnectedUsbDevicesForDriver(driver: str) -> Iterator[DeviceMatch]: for match in usbDevs: if driver == _getStandardHidDriverName(): if _isHIDBrailleMatch(match): - if (driver, match.type, match.id) in fallBackDevices: - fallbackMatches.append(match) - else: - yield match + yield match else: - devs = _driverDevices[driver] - for type, ids in devs.items(): - if match.type == type and match.id in ids: - if (driver, match.type, match.id) in fallBackDevices: + usbDefinitions = _driverDevices[driver][CommunicationType.USB] + for definition in usbDefinitions: + if definition.matches(match): + if definition.useAsFallback: fallbackMatches.append(match) else: yield match @@ -527,19 +593,19 @@ def getPossibleBluetoothDevicesForDriver(driver: str) -> Iterator[DeviceMatch]: if driver == _getStandardHidDriverName(): matchFunc = _isHIDBrailleMatch else: - matchFunc = _driverDevices[driver][DeviceType.BLUETOOTH] + matchFunc = _driverDevices[driver][CommunicationType.BLUETOOTH] if not callable(matchFunc): return btDevs = itertools.chain( ( - DeviceMatch(DeviceType.SERIAL, port["bluetoothName"], port["port"], port) + DeviceMatch(ProtocolType.SERIAL, port["bluetoothName"], port["port"], port) for port in deviceInfoFetcher.comPorts if "bluetoothName" in port ), ( - DeviceMatch(DeviceType.HID, port["hardwareID"], port["devicePath"], port) + DeviceMatch(ProtocolType.HID, port["hardwareID"], port["devicePath"], port) for port in deviceInfoFetcher.hidDevices - if port["provider"] == "bluetooth" + if port["provider"] == CommunicationType.BLUETOOTH ), ) for match in btDevs: @@ -602,7 +668,7 @@ def getBrailleDisplayDriversEnabledForDetection() -> Generator[str, Any, Any]: def initialize(): """Initializes bdDetect, such as detection data. - Calls to addUsbDevices, and addBluetoothDevices. + Calls to addUsbDevice, addUsbDevices, and addBluetoothDevices. Specify the requirements for a detected device to be considered a match for a specific driver. """ @@ -646,18 +712,58 @@ def _getDriverDict(self) -> DriverDictT: ret = _driverDevices[self._driver] = DriverDictT(set) return ret - def addUsbDevices(self, type: DeviceType, ids: set[str], useAsFallBack: bool = False): + def addUsbDevice( + self, + type: ProtocolType, + id: str, + useAsFallback: bool = False, + matchFunc: MatchFuncT | None = None, + ): + """Associate an USB device with the driver on this instance. + :param type: The type of the driver. + :param id: A USB ID in the form C{"VID_xxxx&PID_XXXX"}. + Note that alphabetical characters in hexadecimal numbers should be uppercase. + :param useAsFallback: A boolean flag to determine how this USB device is associated with the driver. + If False (default), the device is added directly to the primary driver list for the specified type, + meaning it is immediately available for use with the driver. + If True, the device is used only if the primary driver cannot use + the initial devices, serving as a backup option in case of compatibility issues. + This provides flexibility and robustness in managing driver-device connections. + @param matchFunc: An optional function which determines whether a given device matches. + It takes a L{DeviceMatch} as its only argument + and returns a C{bool} indicating whether it matched. + It can be used to further constrain a device registration, such as for a specific HID usage page. + :raise ValueError: When the provided ID is malformed. + """ + if not isinstance(id, str) or not USB_ID_REGEX.match(id): + raise ValueError( + f"Invalid ID provided for driver {self._driver!r}, type {type!r}: " f"{id!r}", + ) + devs = self._getDriverDict() + driverUsb = devs[CommunicationType.USB] + driverUsb.add(_UsbDeviceRegistryEntry(type, id, useAsFallback, matchFunc)) + + def addUsbDevices( + self, + type: DeviceType, + ids: set[str], + useAsFallback: bool = False, + matchFunc: MatchFuncT = None, + ): """Associate USB devices with the driver on this instance. :param type: The type of the driver. :param ids: A set of USB IDs in the form C{"VID_xxxx&PID_XXXX"}. Note that alphabetical characters in hexadecimal numbers should be uppercase. - :param useAsFallBack: A boolean flag to determine how USB devices are associated with the driver. - + :param useAsFallback: A boolean flag to determine how USB devices are associated with the driver. If False (default), the devices are added directly to the primary driver list for the specified type, meaning they are immediately available for use with the driver. If True, the devices are added to a fallback list and are used only if the primary driver cannot use the initial devices, serving as a backup option in case of compatibility issues. This provides flexibility and robustness in managing driver-device connections. + @param matchFunc: An optional function which determines whether a given device matches. + It takes a L{DeviceMatch} as its only argument + and returns a C{bool} indicating whether it matched. + It can be used to further constrain device registrations, such as for a specific HID usage page. :raise ValueError: When one of the provided IDs is malformed. """ malformedIds = [id for id in ids if not isinstance(id, str) or not USB_ID_REGEX.match(id)] @@ -666,12 +772,9 @@ def addUsbDevices(self, type: DeviceType, ids: set[str], useAsFallBack: bool = F f"Invalid IDs provided for driver {self._driver!r}, type {type!r}: " f"{', '.join(malformedIds)}", ) - if useAsFallBack: - fallBackDevices.update((self._driver, type, id) for id in ids) - devs = self._getDriverDict() - driverUsb = devs[type] - driverUsb.update(ids) + driverUsb = devs[CommunicationType.USB] + driverUsb.update((_UsbDeviceRegistryEntry(id, type, useAsFallback, matchFunc) for id in ids)) def addBluetoothDevices(self, matchFunc: MatchFuncT): """Associate Bluetooth HID or COM ports with the driver on this instance. @@ -680,7 +783,7 @@ def addBluetoothDevices(self, matchFunc: MatchFuncT): and returns a C{bool} indicating whether it matched. """ devs = self._getDriverDict() - devs[DeviceType.BLUETOOTH] = matchFunc + devs[CommunicationType.BLUETOOTH] = matchFunc def addDeviceScanner( self, diff --git a/source/braille.py b/source/braille.py index ba44325c07b..54a70dac138 100644 --- a/source/braille.py +++ b/source/braille.py @@ -3517,7 +3517,7 @@ def _getTryPorts( pass else: yield bdDetect.DeviceMatch( - bdDetect.DeviceType.SERIAL, + bdDetect.ProtocolType.SERIAL, portInfo["bluetoothName" if "bluetoothName" in portInfo else "friendlyName"], portInfo["port"], portInfo, diff --git a/source/brailleDisplayDrivers/albatross/driver.py b/source/brailleDisplayDrivers/albatross/driver.py index 311dd145ceb..55a8568268e 100644 --- a/source/brailleDisplayDrivers/albatross/driver.py +++ b/source/brailleDisplayDrivers/albatross/driver.py @@ -12,7 +12,7 @@ import time from collections import deque -from bdDetect import DeviceType, DriverRegistrar +from bdDetect import DriverRegistrar, ProtocolType from logHandler import log from serial.win32 import ( PURGE_RXABORT, @@ -84,11 +84,11 @@ class BrailleDisplayDriver(braille.BrailleDisplayDriver): @classmethod def registerAutomaticDetection(cls, driverRegistrar: DriverRegistrar): - driverRegistrar.addUsbDevices( - DeviceType.SERIAL, - { - "VID_0403&PID_6001", # Caiku Albatross 46/80 - }, + driverRegistrar.addUsbDevice( + ProtocolType.SERIAL, + VID_AND_PID, # Caiku Albatross 46/80 + # Filter for bus reported device description, which should be "Albatross Braille Display". + matchFunc=lambda match: match.deviceInfo.get("busReportedDeviceDescription") == BUS_DEVICE_DESC, ) @classmethod @@ -168,11 +168,6 @@ def _searchPorts(self, originalPort: str): """ for self._baudRate in BAUD_RATE: for portType, portId, port, portInfo in self._getTryPorts(originalPort): - # Block port if its vid and pid are correct but bus reported - # device description is not "Albatross Braille Display". - if portId == VID_AND_PID and portInfo.get("busReportedDeviceDescription") != BUS_DEVICE_DESC: - log.debug(f"port {port} blocked; port information: {portInfo}") - continue # For reconnection self._currentPort = port self._tryToConnect = True diff --git a/source/brailleDisplayDrivers/alva.py b/source/brailleDisplayDrivers/alva.py index 0402bf31692..73f8aaf63be 100644 --- a/source/brailleDisplayDrivers/alva.py +++ b/source/brailleDisplayDrivers/alva.py @@ -158,7 +158,7 @@ class BrailleDisplayDriver(braille.BrailleDisplayDriver, ScriptableObject): @classmethod def registerAutomaticDetection(cls, driverRegistrar: bdDetect.DriverRegistrar): driverRegistrar.addUsbDevices( - bdDetect.DeviceType.HID, + bdDetect.ProtocolType.HID, { "VID_0798&PID_0640", # BC640 "VID_0798&PID_0680", # BC680 @@ -216,7 +216,7 @@ def __init__(self, port="auto"): self._deviceId = None for portType, portId, port, portInfo in self._getTryPorts(port): - self.isHid = portType == bdDetect.DeviceType.HID + self.isHid = portType == bdDetect.ProtocolType.HID # Try talking to the display. try: if self.isHid: diff --git a/source/brailleDisplayDrivers/baum.py b/source/brailleDisplayDrivers/baum.py index 5ba90c7cc36..e734cf7e234 100644 --- a/source/brailleDisplayDrivers/baum.py +++ b/source/brailleDisplayDrivers/baum.py @@ -84,7 +84,7 @@ class BrailleDisplayDriver(braille.BrailleDisplayDriver): @classmethod def registerAutomaticDetection(cls, driverRegistrar: bdDetect.DriverRegistrar): driverRegistrar.addUsbDevices( - bdDetect.DeviceType.HID, + bdDetect.ProtocolType.HID, { "VID_0904&PID_3001", # RefreshaBraille 18 "VID_0904&PID_6101", # VarioUltra 20 @@ -111,7 +111,7 @@ def registerAutomaticDetection(cls, driverRegistrar: bdDetect.DriverRegistrar): ) driverRegistrar.addUsbDevices( - bdDetect.DeviceType.SERIAL, + bdDetect.ProtocolType.SERIAL, { "VID_0403&PID_FE70", # Vario 40 "VID_0403&PID_FE71", # PocketVario @@ -164,7 +164,7 @@ def __init__(self, port="auto"): for portType, portId, port, portInfo in self._getTryPorts(port): # At this point, a port bound to this display has been found. # Try talking to the display. - self.isHid = portType == bdDetect.DeviceType.HID + self.isHid = portType == bdDetect.ProtocolType.HID try: if self.isHid: self._dev = hwIo.Hid(port, onReceive=self._onReceive) diff --git a/source/brailleDisplayDrivers/brailleNote.py b/source/brailleDisplayDrivers/brailleNote.py index e3ca3b471de..3d843cf8e3d 100644 --- a/source/brailleDisplayDrivers/brailleNote.py +++ b/source/brailleDisplayDrivers/brailleNote.py @@ -130,7 +130,7 @@ class BrailleDisplayDriver(braille.BrailleDisplayDriver): @classmethod def registerAutomaticDetection(cls, driverRegistrar: bdDetect.DriverRegistrar): driverRegistrar.addUsbDevices( - bdDetect.DeviceType.SERIAL, + bdDetect.ProtocolType.SERIAL, { "VID_1C71&PID_C004", # Apex }, diff --git a/source/brailleDisplayDrivers/brailliantB.py b/source/brailleDisplayDrivers/brailliantB.py index 033ce1add0d..e411a7c8b31 100644 --- a/source/brailleDisplayDrivers/brailliantB.py +++ b/source/brailleDisplayDrivers/brailliantB.py @@ -35,6 +35,7 @@ HR_KEYS = b"\x04" HR_BRAILLE = b"\x05" HR_POWEROFF = b"\x07" +HID_USAGE_PAGE = 0x93 KEY_NAMES = { 1: "power", # Brailliant BI 32, 40 and 80. @@ -89,22 +90,28 @@ class BrailleDisplayDriver(braille.BrailleDisplayDriver): @classmethod def registerAutomaticDetection(cls, driverRegistrar: bdDetect.DriverRegistrar): driverRegistrar.addUsbDevices( - bdDetect.DeviceType.HID, + bdDetect.ProtocolType.HID, { "VID_1C71&PID_C111", # Mantis Q 40 "VID_1C71&PID_C101", # Chameleon 20 + "VID_1C71&PID_C131", # Brailliant BI 40X + "VID_1C71&PID_C141", # Brailliant BI 20X + }, + matchFunc=bdDetect.HIDUsagePageMatchFuncFactory(HID_USAGE_PAGE), + ) + driverRegistrar.addUsbDevices( + bdDetect.ProtocolType.HID, + { "VID_1C71&PID_C121", # Humanware BrailleOne 20 HID "VID_1C71&PID_CE01", # NLS eReader 20 HID "VID_1C71&PID_C006", # Brailliant BI 32, 40 and 80 "VID_1C71&PID_C022", # Brailliant BI 14 - "VID_1C71&PID_C131", # Brailliant BI 40X - "VID_1C71&PID_C141", # Brailliant BI 20X "VID_1C71&PID_C00A", # BrailleNote Touch "VID_1C71&PID_C00E", # BrailleNote Touch v2 }, ) driverRegistrar.addUsbDevices( - bdDetect.DeviceType.SERIAL, + bdDetect.ProtocolType.SERIAL, { "VID_1C71&PID_C005", # Brailliant BI 32, 40 and 80 "VID_1C71&PID_C021", # Brailliant BI 14 @@ -112,24 +119,32 @@ def registerAutomaticDetection(cls, driverRegistrar: bdDetect.DriverRegistrar): ) driverRegistrar.addBluetoothDevices( lambda m: ( - m.type == bdDetect.DeviceType.SERIAL + m.type == bdDetect.ProtocolType.SERIAL and ( m.id.startswith("Brailliant B") or m.id == "Brailliant 80" or "BrailleNote Touch" in m.id ) ) or ( - m.type == bdDetect.DeviceType.HID + m.type == bdDetect.ProtocolType.HID and m.deviceInfo.get("manufacturer") == "Humanware" - and m.deviceInfo.get("product") - in ( - "Brailliant HID", - "APH Chameleon 20", - "APH Mantis Q40", - "Humanware BrailleOne", - "NLS eReader", - "NLS eReader Humanware", - "Brailliant BI 40X", - "Brailliant BI 20X", + and ( + ( + m.deviceInfo.get("product") + in ( + "APH Chameleon 20", + "APH Mantis Q40", + "Brailliant BI 40X", + "Brailliant BI 20X", + ) + and bdDetect._isHIDUsagePageMatch(m, HID_USAGE_PAGE) + ) + or m.deviceInfo.get("product") + in ( + "Brailliant HID", + "Humanware BrailleOne", + "NLS eReader", + "NLS eReader Humanware", + ) ) ), ) @@ -143,7 +158,7 @@ def __init__(self, port="auto"): self.numCells = 0 for portType, portId, port, portInfo in self._getTryPorts(port): - self.isHid = portType == bdDetect.DeviceType.HID + self.isHid = portType == bdDetect.ProtocolType.HID # Try talking to the display. try: if self.isHid: diff --git a/source/brailleDisplayDrivers/eurobraille/driver.py b/source/brailleDisplayDrivers/eurobraille/driver.py index 2911d90b9fc..ddad0ad50db 100644 --- a/source/brailleDisplayDrivers/eurobraille/driver.py +++ b/source/brailleDisplayDrivers/eurobraille/driver.py @@ -45,7 +45,7 @@ class BrailleDisplayDriver(braille.BrailleDisplayDriver, ScriptableObject): @classmethod def registerAutomaticDetection(cls, driverRegistrar: bdDetect.DriverRegistrar): driverRegistrar.addUsbDevices( - bdDetect.DeviceType.HID, + bdDetect.ProtocolType.HID, { "VID_C251&PID_1122", # Esys (version < 3.0, no SD card "VID_C251&PID_1123", # Esys (version >= 3.0, with HID keyboard, no SD card @@ -67,7 +67,7 @@ def registerAutomaticDetection(cls, driverRegistrar: bdDetect.DriverRegistrar): }, ) driverRegistrar.addUsbDevices( - bdDetect.DeviceType.SERIAL, + bdDetect.ProtocolType.SERIAL, { "VID_28AC&PID_0012", # b.note "VID_28AC&PID_0013", # b.note 2 @@ -97,7 +97,7 @@ def __init__(self, port="Auto"): for portType, portId, port, portInfo in self._getTryPorts(port): # At this point, a port bound to this display has been found. # Try talking to the display. - self.isHid = portType == bdDetect.DeviceType.HID + self.isHid = portType == bdDetect.ProtocolType.HID try: if self.isHid: self._dev = hwIo.Hid( @@ -173,7 +173,7 @@ def terminate(self): def _prepFirstByteStreamAndData( self, data: bytes, - ) -> (bytes, Union[BytesIO, hwIo.IoBase], bytes): + ) -> tuple[bytes, Union[BytesIO, hwIo.IoBase], bytes]: if self.isHid: # data contains the entire packet. # HID Packets start with 0x00. diff --git a/source/brailleDisplayDrivers/freedomScientific.py b/source/brailleDisplayDrivers/freedomScientific.py index fe00e79e721..ce6ebb00901 100755 --- a/source/brailleDisplayDrivers/freedomScientific.py +++ b/source/brailleDisplayDrivers/freedomScientific.py @@ -199,7 +199,7 @@ class BrailleDisplayDriver(braille.BrailleDisplayDriver, ScriptableObject): @classmethod def registerAutomaticDetection(cls, driverRegistrar: bdDetect.DriverRegistrar): driverRegistrar.addUsbDevices( - bdDetect.DeviceType.CUSTOM, + bdDetect.ProtocolType.CUSTOM, { "VID_0F4E&PID_0100", # Focus 1 "VID_0F4E&PID_0111", # PAC Mate @@ -243,7 +243,7 @@ def __init__(self, port="auto"): self.gestureMap.add("br(freedomScientific):rightWizWheelDown", *action[2]) super(BrailleDisplayDriver, self).__init__() for portType, portId, port, portInfo in self._getTryPorts(port): - self.isUsb = portType == bdDetect.DeviceType.CUSTOM + self.isUsb = portType == bdDetect.ProtocolType.CUSTOM # Try talking to the display. try: if self.isUsb: diff --git a/source/brailleDisplayDrivers/handyTech.py b/source/brailleDisplayDrivers/handyTech.py index a5a808027ef..e68555bf72d 100644 --- a/source/brailleDisplayDrivers/handyTech.py +++ b/source/brailleDisplayDrivers/handyTech.py @@ -689,7 +689,7 @@ class BrailleDisplayDriver(braille.BrailleDisplayDriver, ScriptableObject): @classmethod def registerAutomaticDetection(cls, driverRegistrar: bdDetect.DriverRegistrar): driverRegistrar.addUsbDevices( - bdDetect.DeviceType.SERIAL, + bdDetect.ProtocolType.SERIAL, { "VID_0403&PID_6001", # FTDI chip "VID_0921&PID_1200", # GoHubs chip @@ -698,7 +698,7 @@ def registerAutomaticDetection(cls, driverRegistrar: bdDetect.DriverRegistrar): # Newer Handy Tech displays have a native HID processor driverRegistrar.addUsbDevices( - bdDetect.DeviceType.HID, + bdDetect.ProtocolType.HID, { "VID_1FE4&PID_0054", # Active Braille "VID_1FE4&PID_0055", # Connect Braille @@ -723,7 +723,7 @@ def registerAutomaticDetection(cls, driverRegistrar: bdDetect.DriverRegistrar): # Some older HT displays use a HID converter and an internal serial interface driverRegistrar.addUsbDevices( - bdDetect.DeviceType.HID, + bdDetect.ProtocolType.HID, { "VID_1FE4&PID_0003", # USB-HID adapter "VID_1FE4&PID_0074", # Braille Star 40 @@ -774,7 +774,7 @@ def __init__(self, port="auto"): for portType, portId, port, portInfo in self._getTryPorts(port): # At this point, a port bound to this display has been found. # Try talking to the display. - self.isHid = portType == bdDetect.DeviceType.HID + self.isHid = portType == bdDetect.ProtocolType.HID self.isHidSerial = portId in USB_IDS_HID_CONVERTER self.port = port try: diff --git a/source/brailleDisplayDrivers/hidBrailleStandard.py b/source/brailleDisplayDrivers/hidBrailleStandard.py index 3e06d5e3912..c7e27ea5300 100644 --- a/source/brailleDisplayDrivers/hidBrailleStandard.py +++ b/source/brailleDisplayDrivers/hidBrailleStandard.py @@ -95,7 +95,7 @@ def __init__(self, port="auto"): self.numCols = 0 for portType, portId, port, portInfo in self._getTryPorts(port): - if portType != bdDetect.DeviceType.HID: + if portType != bdDetect.ProtocolType.HID: continue # Try talking to the display. try: diff --git a/source/brailleDisplayDrivers/hims.py b/source/brailleDisplayDrivers/hims.py index a129c8c68fd..25bd648bebd 100644 --- a/source/brailleDisplayDrivers/hims.py +++ b/source/brailleDisplayDrivers/hims.py @@ -281,20 +281,20 @@ class BrailleDisplayDriver(braille.BrailleDisplayDriver): @classmethod def registerAutomaticDetection(cls, driverRegistrar: bdDetect.DriverRegistrar): deviceTypes = { - bdDetect.DeviceType.HID: ( + bdDetect.ProtocolType.HID: ( { "VID_045E&PID_940A", # Braille Edge3S 40 }, True, ), - bdDetect.DeviceType.CUSTOM: ( + bdDetect.ProtocolType.CUSTOM: ( { "VID_045E&PID_930A", # Braille Sense & Smart Beetle "VID_045E&PID_930B", # Braille EDGE 40 }, False, ), - bdDetect.DeviceType.SERIAL: ( + bdDetect.ProtocolType.SERIAL: ( { "VID_0403&PID_6001", "VID_1A86&PID_55D3", # Braille Edge2S 40 @@ -329,17 +329,17 @@ def __init__(self, port="auto"): for match in self._getTryPorts(port): portType, portId, port, portInfo = match - self.isBulk = portType == bdDetect.DeviceType.CUSTOM - self.isHID = portType == bdDetect.DeviceType.HID + self.isBulk = portType == bdDetect.ProtocolType.CUSTOM + self.isHID = portType == bdDetect.ProtocolType.HID # Try talking to the display. try: match portType: - case bdDetect.DeviceType.HID: + case bdDetect.ProtocolType.HID: self._dev = hwIo.Hid(port, onReceive=self._hidOnReceive) - case bdDetect.DeviceType.CUSTOM: + case bdDetect.ProtocolType.CUSTOM: # onReceiveSize based on max packet size according to USB endpoint information. self._dev = hwIo.Bulk(port, 0, 1, self._onReceive, onReceiveSize=64) - case bdDetect.DeviceType.SERIAL: + case bdDetect.ProtocolType.SERIAL: self._dev = hwIo.Serial( port, baudrate=BAUD_RATE, diff --git a/source/brailleDisplayDrivers/nattiqbraille.py b/source/brailleDisplayDrivers/nattiqbraille.py index d83955d8130..9088ed5c080 100644 --- a/source/brailleDisplayDrivers/nattiqbraille.py +++ b/source/brailleDisplayDrivers/nattiqbraille.py @@ -39,7 +39,7 @@ class BrailleDisplayDriver(braille.BrailleDisplayDriver): @classmethod def registerAutomaticDetection(cls, driverRegistrar: bdDetect.DriverRegistrar): driverRegistrar.addUsbDevices( - bdDetect.DeviceType.SERIAL, + bdDetect.ProtocolType.SERIAL, { "VID_2341&PID_8036", # Atmel-based USB Serial for Nattiq nBraille }, diff --git a/source/brailleDisplayDrivers/seikantk.py b/source/brailleDisplayDrivers/seikantk.py index 28beb399ae2..e7e1b0d16b4 100644 --- a/source/brailleDisplayDrivers/seikantk.py +++ b/source/brailleDisplayDrivers/seikantk.py @@ -16,7 +16,7 @@ import serial import braille -from bdDetect import DeviceType, DeviceMatch, DriverRegistrar +from bdDetect import DeviceMatch, DriverRegistrar import brailleInput import inputCore import bdDetect @@ -107,7 +107,7 @@ class BrailleDisplayDriver(braille.BrailleDisplayDriver): @classmethod def registerAutomaticDetection(cls, driverRegistrar: DriverRegistrar): driverRegistrar.addUsbDevices( - DeviceType.HID, + bdDetect.ProtocolType.HID, { vidpid, # Seika Notetaker }, @@ -134,8 +134,8 @@ def __init__(self, port: typing.Union[None, str, DeviceMatch]): log.debug(f"Seika Notetaker braille driver: ({port!r})") dev: typing.Optional[typing.Union[hwIo.Hid, hwIo.Serial]] = None for match in self._getTryPorts(port): - self.isHid = match.type == bdDetect.DeviceType.HID - self.isSerial = match.type == bdDetect.DeviceType.SERIAL + self.isHid = match.type == bdDetect.ProtocolType.HID + self.isSerial = match.type == bdDetect.ProtocolType.SERIAL try: if self.isHid: log.info("Trying Seika notetaker on USB-HID") diff --git a/source/brailleDisplayDrivers/superBrl.py b/source/brailleDisplayDrivers/superBrl.py index 10a28ee856a..beef924445f 100644 --- a/source/brailleDisplayDrivers/superBrl.py +++ b/source/brailleDisplayDrivers/superBrl.py @@ -34,7 +34,7 @@ class BrailleDisplayDriver(braille.BrailleDisplayDriver): @classmethod def registerAutomaticDetection(cls, driverRegistrar: bdDetect.DriverRegistrar): driverRegistrar.addUsbDevices( - bdDetect.DeviceType.SERIAL, + bdDetect.ProtocolType.SERIAL, { "VID_10C4&PID_EA60", # SuperBraille 3.2 }, diff --git a/source/hwPortUtils.py b/source/hwPortUtils.py index 29dc7ed0002..de926c07a78 100644 --- a/source/hwPortUtils.py +++ b/source/hwPortUtils.py @@ -16,6 +16,7 @@ import hidpi import winKernel from logHandler import log +from winAPI.constants import SystemErrorCodes from winKernel import SYSTEMTIME @@ -496,7 +497,10 @@ def __init__(self, **kwargs): super().__init__(Size=ctypes.sizeof(HIDD_ATTRIBUTES), **kwargs) -def _getHidInfo(hwId, path): +_getHidInfoCache: dict[str, dict] = {} + + +def _getHidInfo(hwId: str, path: str) -> dict[str, typing.Any]: info = { "hardwareID": hwId, "devicePath": path, @@ -517,18 +521,28 @@ def _getHidInfo(hwId, path): # Fetch additional info about the HID device. from serial.win32 import FILE_FLAG_OVERLAPPED, INVALID_HANDLE_VALUE, CreateFile - 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.debugWarning(f"Opening device {path} to get additional info failed: {ctypes.WinError()}") + if ( + handle := CreateFile( + path, + 0, + winKernel.FILE_SHARE_READ | winKernel.FILE_SHARE_WRITE, + None, + winKernel.OPEN_EXISTING, + FILE_FLAG_OVERLAPPED, + None, + ) + ) == INVALID_HANDLE_VALUE: + if (err := ctypes.GetLastError()) == SystemErrorCodes.SHARING_VIOLATION: + if _isDebug(): + log.debugWarning( + f"Opening device {path} to get additional info failed because the device is being used. " + "Falling back to cache for device info", + ) + if cachedInfo := _getHidInfoCache.get(path): + cachedInfo.update(info) + return cachedInfo + elif _isDebug(): + log.debugWarning(f"Opening device {path} to get additional info failed: {ctypes.WinError(err)}") return info try: attribs = HIDD_ATTRIBUTES() @@ -555,6 +569,7 @@ def _getHidInfo(hwId, path): ctypes.windll.hid.HidD_FreePreparsedData(pd) finally: winKernel.closeHandle(handle) + _getHidInfoCache[path] = info return info diff --git a/source/winAPI/constants.py b/source/winAPI/constants.py index a7f1bc07fe3..41e60a3562b 100644 --- a/source/winAPI/constants.py +++ b/source/winAPI/constants.py @@ -28,6 +28,8 @@ class SystemErrorCodes(enum.IntEnum): ACCESS_DENIED = 0x5 INVALID_DATA = 0xD NOT_READY = 0x15 + SHARING_VIOLATION = 0x20 + """The process cannot access the file because it is being used by another process.""" INVALID_PARAMETER = 0x57 MOD_NOT_FOUND = 0x7E CANCELLED = 0x4C7 diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index 5d2a74f72ab..fa7846e2977 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -81,6 +81,8 @@ Specifically, MathML inside of span and other elements that have the attribute ` In any document, if the cursor is on the last line, it will be moved to the end when using this command. (#17251, #17430, @nvdaes) * In web browsers, changes to text selection no longer sometimes fail to be reported in editable text controls. (#17501, @jcsteh) +* When the Standard HID Braille Display driver is explicitly selected as the braille display driver, and the braille display list is opened, NVDA correctly identifies the HID driver as the selected driver instead of showing no driver selected. (#17537, @LeonarddeR) +* The Humanware Brailliant driver is now more reliable in selecting the right connection endpoint, resulting in better connection stability and less errors. (#17537, @LeonarddeR) * Custom braille tables in the developer scratchpad are now properly ignored when running with add-ons disabled. (#17565, @LeonarddeR) ### Changes for Developers @@ -125,6 +127,11 @@ Add-ons will need to be re-tested and have their manifest updated. * Added the following extension points (#17428, @ctoth): * `inputCore.decide_handleRawKey`: called on each keypress * `speech.extensions.post_speechPaused`: called when speech is paused or unpaused +* Changes to braille display auto detection registration in `bdDetect.DriverRegistrar`: (#17521, @LeonarddeR) + * Added the `addUsbDevice` method to register one USB device at a time. + * Added the `matchFunc` parameter to `addUsbDevices` which is also available on `addUsbDevice`. + * This way device detection can be constrained further in cases where a VID/PID-combination is shared by multiple devices across multiple drivers, or when a HID device offers multiple endpoints, for example. + * See the method documentation as well as examples in the albatross and brailliantB drivers for more information. #### API Breaking Changes @@ -152,6 +159,7 @@ As the NVDA update check URL is now configurable directly within NVDA, no replac * In `NVDAObjects.window.scintilla.ScintillaTextInfo`, if no text is selected, the `collapse` method is overriden to expand to line if the `end` parameter is set to `True` (#17431, @nvdaes) * The following symbols have been removed with no replacement: `languageHandler.getLanguageCliArgs`, `__main__.quitGroup` and `__main__.installGroup` . (#17486, @CyrilleB79) * Prefix matching on command line flags, e.g. using `--di` for `--disable-addons` is no longer supported. (#11644, @CyrilleB79) +* The `useAsFallBack` keyword argument of `bdDetect.DriverRegistrar` has been renamed to `useAsFallback`. (#17521, @LeonarddeR) #### Deprecations @@ -160,6 +168,7 @@ Please use `braille.filter_displayDimensions` instead. (#17011) * The following symbols are deprecated (#17486, @CyrilleB79): * `NoConsoleOptionParser`, `stringToBool`, `stringToLang` in `__main__`; use the same symbols in `argsParsing` instead. * `__main__.parser`; use `argsParsing.getParser()` instead. +* `bdDetect.DeviceType` is deprecated in favour of `bdDetect.ProtocolType` and `bdDetect.CommunicationType` to take into account the fact that both HID and Serial communication can take place over USB and Bluetooth. (#17537 , @LeonarddeR) ## 2024.4.2