diff --git a/pyproject.toml b/pyproject.toml index 20b86bb47a4..f7895da7d6b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ readme = "readme.md" license = {file = "copying.txt"} dependencies = [ # NVDA's runtime dependencies + "bleak==3.0.0", "comtypes==1.4.13", "cryptography==46.0.6", "pyserial==3.5", diff --git a/source/hwIo/ble/__init__.py b/source/hwIo/ble/__init__.py new file mode 100644 index 00000000000..e19c5273c86 --- /dev/null +++ b/source/hwIo/ble/__init__.py @@ -0,0 +1,68 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2025-2026 NV Access Limited, Dot Incorporated, Bram Duvigneau +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +"""Raw I/O for Bluetooth Low Energy (BLE) devices + +This module provides classes for scanning for BLE devices and communicating with them. +It uses the Bleak library for BLE communication. + +Only use this if you need access to a device that only implements BLE and not Bluetooth Classic. +Bluetooth Classic devices should be paired through Windows' Bluetooth settings and accessed through the related serial/HID device. +""" + +import time +from bleak.exc import BleakError +from bleak.backends.device import BLEDevice +from logHandler import log + +from ._scanner import Scanner # noqa: F401 +from ._io import Ble # noqa: F401 + +#: Module-level singleton scanner shared by all BLE consumers. +#: Using a single scanner avoids contention over the Windows BLE stack and +#: lets multiple callers share the set of already-discovered devices. +scanner = Scanner() + + +def findDeviceByAddress(address: str, timeout: float = 5.0, pollInterval: float = 0.1) -> BLEDevice | None: + """Find a BLE device by its address. + + Checks already-discovered devices first, then scans if needed. + + :param address: The BLE device address (MAC address) + :param timeout: Maximum time to scan in seconds (default 5.0) + :param pollInterval: How often to check results in seconds (default 0.1) + :return: The BLE device object if found, None otherwise + """ + log.debug(f"Searching for BLE device with address {address}") + + # Check if device already discovered + for device in scanner.results(): + if device.address == address: + log.debug(f"Found BLE device {address} in existing results") + return device + + # Not found - start scanning if not already running + if not scanner.isScanning: + try: + scanner.start() # Start in background mode + except (BleakError, OSError): + log.error(f"Failed to start BLE scanner while searching for device {address}", exc_info=True) + return None + + startTime = time.time() + while time.time() - startTime < timeout: + time.sleep(pollInterval) + + # Check if device appeared + for device in scanner.results(): + if device.address == address: + elapsed = time.time() - startTime + log.debug(f"Found BLE device {address} after {elapsed:.2f}s") + return device + + # Timeout - device not found + log.debug(f"BLE device {address} not found after {timeout}s timeout") + return None diff --git a/source/hwIo/ble/_io.py b/source/hwIo/ble/_io.py new file mode 100644 index 00000000000..9ec414c888e --- /dev/null +++ b/source/hwIo/ble/_io.py @@ -0,0 +1,243 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2025-2026 NV Access Limited, Dot Incorporated, Bram Duvigneau +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. +import time +from itertools import count, takewhile +from queue import Empty, Queue +from threading import Event, Thread +from typing import Callable, Iterator +import weakref + +from _asyncioEventLoop.utils import runCoroutineSync +from ..base import _isDebug, IoBase +from ..ioThread import IoThread +from logHandler import log + +import bleak +from bleak.backends.device import BLEDevice +from bleak.backends.characteristic import BleakGATTCharacteristic +from bleak.backends.winrt.client import WinRTClientArgs + +CONNECT_TIMEOUT_SECONDS: int = 2 +WINRT_CLIENT_ARGS = WinRTClientArgs(use_cached_services=True) + + +def queueReader( + queue: Queue[bytes], + onReceive: Callable[[bytes], None], + stopEvent: Event, + ioThread: IoThread, +) -> None: + """Background thread loop that dispatches queued BLE notification data. + + Runs in its own daemon thread for each `Ble` instance. Pulls chunks of + received data off `queue` as they arrive from the Bleak notification + callback, and hands each chunk off to `onReceive` via + `ioThread.queueAsApc` so the callback runs on the shared NVDA I/O + thread (matching the behaviour of other `hwIo` transports). Exits + cleanly when `stopEvent` is set. `OSError` from the I/O thread path + is logged and the loop continues so one transient failure does not + kill the reader. + + :param queue: Queue that `Ble._notifyReceive` pushes received bytes to. + :param onReceive: Callback to invoke on the I/O thread with each chunk. + :param stopEvent: Set by `Ble.close()` to make the loop exit. + :param ioThread: Shared NVDA I/O thread used to run `onReceive`. + """ + while True: + if stopEvent.is_set(): + log.debug("Reader thread got stop event") + break + try: + data: bytes = queue.get(timeout=0.2) + except Empty: + continue + + def apc(_x: int = 0): + return onReceive(data) + + try: + ioThread.queueAsApc(apc) + except OSError: + log.error("Reader thread failed to queue APC", exc_info=True) + queue.task_done() + + +def sliced(data: bytes, n: int) -> Iterator[bytes]: + """Split data into chunks of size n (last chunk may be smaller).""" + return takewhile(len, (data[i : i + n] for i in count(0, n))) + + +class Ble(IoBase): + """I/O for Bluetooth Low Energy (BLE) devices + + This implementation expects a service/characteristic pair to send raw data to as a BLE command + and receive raw data through a BLE notify on a service/characteristic pair. + """ + + _client: bleak.BleakClient + "The Bleak client to use for BLE communication" + _writeServiceUuid: str + "The service UUID to use for writing data to the peripheral, this should accept BLE commands" + _writeCharacteristicUuid: str + "The characteristic UUID to use for writing data to the peripheral, this should accept BLE commands" + _readServiceUuid: str + "The service UUID to use for reading data from the peripheral, this should generate BLE notifications" + _readCharacteristicUuid: str + """The characteristic UUID to use for reading data from the peripheral, + this should generate BLE notifications""" + _onReceive: Callable[[bytes], None] | None + "The callback to call when data is received" + _queuedData: Queue[bytes | bytearray] + "A queue of received data, this is processed by the onReceive handler" + _readEvent: Event + "An event that is set when data is received" + _readerThread: Thread + "Thread that processes the queue of read data" + _stopReaderEvent: Event + "Event that is set to stop the reader thread" + _ioThreadRef: weakref.ReferenceType[IoThread] + "Reference to the I/O thread" + + def __init__( + self, + device: BLEDevice | str, + writeServiceUuid: str, + writeCharacteristicUuid: str, + readServiceUuid: str, + readCharacteristicUuid: str, + onReceive: Callable[[bytes], None], + ioThread: IoThread | None = None, + ) -> None: + if isinstance(device, str): + # String address provided - Bleak will perform implicit discovery + address = device + log.info(f"Connecting to BLE device at address {address}") + self._client = bleak.BleakClient(address, winrt=WINRT_CLIENT_ARGS) + else: + # BLEDevice object provided (preferred) + log.info(f"Connecting to {device.name} ({device.address})") + self._client = bleak.BleakClient(device, winrt=WINRT_CLIENT_ARGS) + self._writeServiceUuid = writeServiceUuid + self._writeCharacteristicUuid = writeCharacteristicUuid + self._readServiceUuid = readServiceUuid + self._readCharacteristicUuid = readCharacteristicUuid + self._onReceive = onReceive + if ioThread is None: + from .. import bgThread as ioThread + self._ioThreadRef = weakref.ref(ioThread) + self._queuedData = Queue() + self._readEvent = Event() + self._stopReaderEvent = Event() + self._readerThread = Thread( + target=queueReader, + args=(self._queuedData, self._onReceive, self._stopReaderEvent, ioThread), + daemon=True, + ) + self._readerThread.start() + runCoroutineSync(self._initAndConnect()) + self.waitForConnection(CONNECT_TIMEOUT_SECONDS) + + async def _initAndConnect(self) -> None: + await self._client.connect() + # Listen for notifications + await self._client.start_notify(self._readCharacteristicUuid, self._notifyReceive) + + def waitForRead(self, timeout: int | float) -> bool: + """Wait for data to be received from the peripheral.""" + self._readEvent.clear() + return self._readEvent.wait(timeout) + + def write(self, data: bytes): + """Write data to the connected BLE peripheral. + + Data is automatically split into MTU-sized chunks if needed. + + :param data: The data to write to the peripheral. + :raises RuntimeError: If not connected or service/characteristic not found. + """ + if not self._client.is_connected: + raise RuntimeError("Not connected to peripheral") + service = self._client.services.get_service(self._writeServiceUuid) + if not service: + raise RuntimeError(f"Service {self._writeServiceUuid} not found") + characteristic = service.get_characteristic(self._writeCharacteristicUuid) + if not characteristic: + raise RuntimeError(f"Characteristic {self._writeCharacteristicUuid} not found") + if _isDebug(): + log.debug(f"Write: {data!r}") + + # Split the data into chunks that fit within the MTU + for s in sliced(data, characteristic.max_write_without_response_size): + runCoroutineSync( + self._client.write_gatt_char(characteristic, s, response=False), + ) + + def close(self) -> None: + """Disconnect the BLE peripheral and release resources.""" + if _isDebug(): + log.debug("Closing BLE connection") + if self._client.is_connected: + runCoroutineSync(self._client.disconnect()) + self._queuedData.join() + self._stopReaderEvent.set() + self._readerThread.join() + + self._onReceive = None + + def __del__(self): + """Ensure the BLE connection is closed before object destruction.""" + try: + self.close() + except AttributeError: + if _isDebug(): + log.debugWarning("Couldn't delete object gracefully", exc_info=True) + + def isConnected(self) -> bool: + """Check if the BLE peripheral is currently connected.""" + return self._client.is_connected + + def waitForConnection(self, maxWait: int | float): + """Wait for connection and service discovery. + + :param maxWait: Maximum time to wait in seconds. + :raises RuntimeError: If connection not established within maxWait. + """ + numTries = 0 + sleepTime = 0.1 + + while (sleepTime * numTries) < maxWait: + if _isDebug(): + services = [ + ( + s.uuid, + s.description, + ) + for s in self._client.services.services.values() + ] + log.debug( + f"Waiting for connection, {numTries} tries, " + f"is connected {self.isConnected()}, services {services}", + ) + if self._client.is_connected and len(self._client.services.services) > 0: + return + time.sleep(sleepTime) + numTries += 1 + raise RuntimeError("Connection timed out") + + def _notifyReceive(self, _char: BleakGATTCharacteristic, data: bytearray): + if _isDebug(): + log.debug(f"Read: {data!r}") + self._readEvent.set() + self._queuedData.put(data) + + def read(self, size: int = 1) -> bytes: + """Not implemented for BLE. + + BLE communication uses a push model with notifications rather than polling reads. + Data is received asynchronously via the onReceive callback provided during initialization. + + :raises NotImplementedError: Always, as BLE doesn't support synchronous reads + """ + raise NotImplementedError("BLE uses notification-based communication, not polling reads") diff --git a/source/hwIo/ble/_scanner.py b/source/hwIo/ble/_scanner.py new file mode 100644 index 00000000000..4b5f940c4dc --- /dev/null +++ b/source/hwIo/ble/_scanner.py @@ -0,0 +1,88 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2025-2026 NV Access Limited, Dot Incorporated, Bram Duvigneau +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +import time +from threading import Event +from typing import Callable + +from _asyncioEventLoop.utils import runCoroutine +import extensionPoints +from logHandler import log + +import bleak +from bleak.backends.device import BLEDevice +from bleak.backends.scanner import AdvertisementData + + +class Scanner: + """Scan for BLE devices + + This is a small synchronous wrapper around Bleak's Scanner. + It allows starting and stopping scans, retrieving results, and checking if scanning is active. + """ + + _scanner: bleak.BleakScanner + _discoveredDevices: dict[str, BLEDevice] + _isScanning: Event + + def __init__(self): + self._discoveredDevices = {} + self._scanner = bleak.BleakScanner(self._onDeviceAdvertised) + self._isScanning = Event() + #: Action called when a BLE device is discovered or re-advertises. + #: Handlers receive: device (BLEDevice), advertisementData (AdvertisementData), isNew (bool) + self.deviceDiscovered = extensionPoints.Action() + + def _onDeviceAdvertised(self, device: BLEDevice, adv: AdvertisementData) -> None: + # Check if this is a new device before updating the dict + isNew = device.address not in self._discoveredDevices + + # Store all devices, even those without a local_name + # Devices without names can still be found by address in findDeviceByAddress() + self._discoveredDevices[device.address] = device + + # Notify extension point handlers + self.deviceDiscovered.notify(device=device, advertisementData=adv, isNew=isNew) + + if isNew: + log.debug(f"Discovered BLE device: {device.name or device.address}") + + def start(self, duration: float = 0): + """Start scanning for BLE devices. + + :param duration: If 0 (default), scan continues in background until stop() is called. + If > 0, scan for specified duration in seconds then stop automatically. + """ + log.debug("Scanning for devices") + # Clear device cache only on first start to allow multiple callers to share results + if not self._isScanning.is_set(): + self._discoveredDevices.clear() + self._isScanning.set() + runCoroutine(self._scanner.start()) + if duration > 0: + time.sleep(duration) + runCoroutine(self._scanner.stop()) + self._isScanning.clear() + + def stop(self): + """Stop scanning""" + runCoroutine(self._scanner.stop()) + self._isScanning.clear() + + def results(self, filterFunc: Callable[[BLEDevice], bool] | None = None) -> list[BLEDevice]: + """Get the discovered BLE devices. + + :param filterFunc: Optional filter function to select specific devices. + :return: List of BLE devices found during the scan, optionally filtered. + """ + results = list(self._discoveredDevices.values()) + if filterFunc: + results = [device for device in results if filterFunc(device)] + return results + + @property + def isScanning(self) -> bool: + """Check if scanning is currently active""" + return self._isScanning.is_set() diff --git a/tests/unit/test_hwIo_ble.py b/tests/unit/test_hwIo_ble.py new file mode 100644 index 00000000000..76fe51e8311 --- /dev/null +++ b/tests/unit/test_hwIo_ble.py @@ -0,0 +1,441 @@ +# A part of NonVisual Desktop Access (NVDA) +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. +# Copyright (C) 2025-2026 NV Access Limited, Dot Incorporated, Bram Duvigneau + +"""Unit tests for the hwIo.ble module. + +These tests cover the BLE scanner, BLE I/O, and device discovery functionality. +""" + +import unittest +from unittest.mock import MagicMock, patch + +from bleak.backends.device import BLEDevice +from bleak.backends.scanner import AdvertisementData + + +class TestScanner(unittest.TestCase): + """Tests for hwIo.ble.Scanner""" + + def setUp(self): + """Set up patches and create Scanner instance.""" + self.bleakScannerPatcher = patch("hwIo.ble._scanner.bleak.BleakScanner") + self.mockBleakScannerClass = self.bleakScannerPatcher.start() + + mockFuture = MagicMock() + mockFuture.result.return_value = None + mockFuture.exception.return_value = None + + def fakeRunCoroutine(coro: object) -> MagicMock: + if hasattr(coro, "close"): + coro.close() + return mockFuture + + self.runCoroutinePatcher = patch( + "hwIo.ble._scanner.runCoroutine", + side_effect=fakeRunCoroutine, + ) + self.mockRunCoroutine = self.runCoroutinePatcher.start() + + # Use regular MagicMock for start/stop: the coroutines they + # return are immediately closed by fakeRunCoroutine without + # being awaited, so they don't need to be awaitable. + self.mockScannerInstance = MagicMock() + self.mockBleakScannerClass.return_value = self.mockScannerInstance + + from hwIo.ble._scanner import Scanner + + self.Scanner = Scanner + + def tearDown(self): + """Clean up patches.""" + self.runCoroutinePatcher.stop() + self.bleakScannerPatcher.stop() + + def test_startScanning(self): + """Test that starting scan calls Bleak scanner and sets isScanning flag.""" + scanner = self.Scanner() + + self.assertFalse(scanner.isScanning) + + scanner.start(duration=0) + + self.mockScannerInstance.start.assert_called_once() + self.assertTrue(scanner.isScanning) + + def test_stopScanning(self): + """Test that stopping scan calls Bleak scanner and clears isScanning flag.""" + scanner = self.Scanner() + scanner.start(duration=0) + + self.assertTrue(scanner.isScanning) + + scanner.stop() + + self.mockScannerInstance.stop.assert_called_once() + self.assertFalse(scanner.isScanning) + + def test_deviceDiscoveredExtensionPoint(self): + """Test that deviceDiscovered extension point fires when device is advertised.""" + scanner = self.Scanner() + + handlerCalls = [] + + def testHandler(device, advertisementData, isNew): + handlerCalls.append( + { + "device": device, + "advertisementData": advertisementData, + "isNew": isNew, + }, + ) + + scanner.deviceDiscovered.register(testHandler) + + fakeDevice = MagicMock(spec=BLEDevice) + fakeDevice.address = "AA:BB:CC:DD:EE:FF" + fakeDevice.name = "TestDevice" + fakeAdvData = MagicMock(spec=AdvertisementData) + + scanner._onDeviceAdvertised(fakeDevice, fakeAdvData) + + self.assertEqual(len(handlerCalls), 1) + self.assertEqual(handlerCalls[0]["device"], fakeDevice) + self.assertEqual(handlerCalls[0]["advertisementData"], fakeAdvData) + self.assertTrue(handlerCalls[0]["isNew"]) + + # Same device again - should not be new + scanner._onDeviceAdvertised(fakeDevice, fakeAdvData) + + self.assertEqual(len(handlerCalls), 2) + self.assertFalse(handlerCalls[1]["isNew"]) + + def test_deviceTracking(self): + """Test that devices are tracked in internal dict and returned by results().""" + scanner = self.Scanner() + + fakeDevice = MagicMock(spec=BLEDevice) + fakeDevice.address = "AA:BB:CC:DD:EE:FF" + fakeDevice.name = "TestDevice" + fakeAdvData = MagicMock(spec=AdvertisementData) + + self.assertEqual(len(scanner.results()), 0) + + scanner._onDeviceAdvertised(fakeDevice, fakeAdvData) + + self.assertIn(fakeDevice.address, scanner._discoveredDevices) + self.assertEqual(scanner._discoveredDevices[fakeDevice.address], fakeDevice) + + results = scanner.results() + self.assertEqual(len(results), 1) + self.assertEqual(results[0], fakeDevice) + + def test_resultsFiltering(self): + """Test that results() filter function works correctly.""" + scanner = self.Scanner() + + device1 = MagicMock(spec=BLEDevice) + device1.address = "AA:BB:CC:DD:EE:01" + device1.name = "TestDevice1" + + device2 = MagicMock(spec=BLEDevice) + device2.address = "AA:BB:CC:DD:EE:02" + device2.name = "OtherDevice" + + device3 = MagicMock(spec=BLEDevice) + device3.address = "AA:BB:CC:DD:EE:03" + device3.name = "TestDevice2" + + fakeAdvData = MagicMock(spec=AdvertisementData) + + scanner._onDeviceAdvertised(device1, fakeAdvData) + scanner._onDeviceAdvertised(device2, fakeAdvData) + scanner._onDeviceAdvertised(device3, fakeAdvData) + + allResults = scanner.results() + self.assertEqual(len(allResults), 3) + + filteredResults = scanner.results(filterFunc=lambda d: d.name.startswith("Test")) + self.assertEqual(len(filteredResults), 2) + self.assertIn(device1, filteredResults) + self.assertIn(device3, filteredResults) + self.assertNotIn(device2, filteredResults) + + +class TestBle(unittest.TestCase): + """Tests for hwIo.ble.Ble""" + + def setUp(self): + """Set up patches for Ble testing.""" + self.bleakClientPatcher = patch("hwIo.ble._io.bleak.BleakClient") + self.mockBleakClientClass = self.bleakClientPatcher.start() + + # Use regular MagicMock for client methods (not AsyncMock): + # the Ble class wraps every async call in runCoroutineSync(), + # which is itself mocked, so the coroutines returned by + # _initAndConnect / disconnect / write_gatt_char are immediately + # closed by fakeRunCoroutineSync without ever being awaited. The + # inner self._client.connect() etc. are therefore never called, + # so they don't need to be awaitable. + self.mockClient = MagicMock() + self.mockClient.is_connected = True + self.mockBleakClientClass.return_value = self.mockClient + + self.mockService = MagicMock() + self.mockCharacteristic = MagicMock() + self.mockCharacteristic.max_write_without_response_size = 20 + self.mockService.get_characteristic.return_value = self.mockCharacteristic + + self.mockServices = MagicMock() + self.mockServices.get_service.return_value = self.mockService + mockServicesDict = MagicMock() + # Configure the MagicMock's __len__ via return_value because Python + # looks up dunder methods on the type, not the instance. + mockServicesDict.__len__.return_value = 1 + mockServicesDict.values.return_value = [self.mockService] + self.mockServices.services = mockServicesDict + self.mockClient.services = self.mockServices + + # Ble uses runCoroutineSync() which blocks until the coroutine + # completes and returns the result directly. Close the passed + # coroutine immediately so Python does not warn about "coroutine + # was never awaited" — the mock will never actually run it. + def fakeRunCoroutineSync(coro: object, timeout: float | None = None) -> None: + if hasattr(coro, "close"): + coro.close() + return None + + self.runCoroutineSyncPatcher = patch( + "hwIo.ble._io.runCoroutineSync", + side_effect=fakeRunCoroutineSync, + ) + self.mockRunCoroutineSync = self.runCoroutineSyncPatcher.start() + + from hwIo.ble._io import Ble + + self.Ble = Ble + # Track Ble instances so tearDown can close them while patches + # are still active (avoiding __del__ errors at GC time when the + # real runCoroutineSync would be called against a dead event + # loop). + self._bleInstances: list[Ble] = [] + + def tearDown(self): + """Clean up Ble instances and patches.""" + # Mark the mock client disconnected so the close() called from + # Ble.__del__ at GC time becomes a no-op (it would otherwise hit + # the real, unmocked runCoroutineSync and raise). + self.mockClient.is_connected = False + for ble in self._bleInstances: + try: + ble.close() + except Exception: + pass + self.runCoroutineSyncPatcher.stop() + self.bleakClientPatcher.stop() + + def _makeBle(self, **kwargs) -> "object": + """Construct a Ble instance and track it for cleanup.""" + ble = self.Ble(**kwargs) + self._bleInstances.append(ble) + return ble + + def test_connectionSuccess(self): + """Test that Ble connects successfully and starts notifications.""" + mockDevice = MagicMock(spec=BLEDevice) + mockDevice.address = "AA:BB:CC:DD:EE:FF" + mockDevice.name = "TestDevice" + + mockIoThread = MagicMock() + + receivedData = [] + + def onReceive(data): + receivedData.append(data) + + ble = self._makeBle( + device=mockDevice, + writeServiceUuid="service-uuid", + writeCharacteristicUuid="write-char-uuid", + readServiceUuid="service-uuid", + readCharacteristicUuid="read-char-uuid", + onReceive=onReceive, + ioThread=mockIoThread, + ) + + self.mockBleakClientClass.assert_called_once() + callArgs = self.mockBleakClientClass.call_args + self.assertEqual(callArgs[0][0], mockDevice) + + self.mockRunCoroutineSync.assert_called() + self.assertTrue(ble.isConnected()) + + def test_writeData(self): + """Test writing data to BLE characteristic.""" + mockDevice = MagicMock(spec=BLEDevice) + mockDevice.address = "AA:BB:CC:DD:EE:FF" + mockDevice.name = "TestDevice" + mockIoThread = MagicMock() + + ble = self._makeBle( + device=mockDevice, + writeServiceUuid="service-uuid", + writeCharacteristicUuid="write-char-uuid", + readServiceUuid="service-uuid", + readCharacteristicUuid="read-char-uuid", + onReceive=lambda data: None, + ioThread=mockIoThread, + ) + + testData = b"test data" + ble.write(testData) + + self.mockServices.get_service.assert_called_with("service-uuid") + self.mockService.get_characteristic.assert_called_with("write-char-uuid") + + self.assertGreater(self.mockRunCoroutineSync.call_count, 1) + + def test_writeDataChunking(self): + """Test that large data is split into MTU-sized chunks.""" + mockDevice = MagicMock(spec=BLEDevice) + mockDevice.address = "AA:BB:CC:DD:EE:FF" + mockDevice.name = "TestDevice" + mockIoThread = MagicMock() + + self.mockCharacteristic.max_write_without_response_size = 10 + + ble = self._makeBle( + device=mockDevice, + writeServiceUuid="service-uuid", + writeCharacteristicUuid="write-char-uuid", + readServiceUuid="service-uuid", + readCharacteristicUuid="read-char-uuid", + onReceive=lambda data: None, + ioThread=mockIoThread, + ) + + initialCallCount = self.mockRunCoroutineSync.call_count + + testData = b"A" * 25 + ble.write(testData) + + writeCalls = self.mockRunCoroutineSync.call_count - initialCallCount + self.assertEqual(writeCalls, 3) + + def test_receiveNotification(self): + """Test receiving data via BLE notification.""" + mockDevice = MagicMock(spec=BLEDevice) + mockDevice.address = "AA:BB:CC:DD:EE:FF" + mockDevice.name = "TestDevice" + mockIoThread = MagicMock() + + receivedData: list[bytes] = [] + + def onReceive(data: bytes) -> None: + receivedData.append(data) + + ble = self._makeBle( + device=mockDevice, + writeServiceUuid="service-uuid", + writeCharacteristicUuid="write-char-uuid", + readServiceUuid="service-uuid", + readCharacteristicUuid="read-char-uuid", + onReceive=onReceive, + ioThread=mockIoThread, + ) + + self.mockRunCoroutineSync.assert_called() + + testData = bytearray(b"notification data") + mockChar = MagicMock() + ble._notifyReceive(mockChar, testData) + + self.assertFalse(ble._queuedData.empty()) + + def test_closeCleanup(self): + """Test that close() properly disconnects and cleans up resources.""" + mockDevice = MagicMock(spec=BLEDevice) + mockDevice.address = "AA:BB:CC:DD:EE:FF" + mockDevice.name = "TestDevice" + mockIoThread = MagicMock() + + ble = self._makeBle( + device=mockDevice, + writeServiceUuid="service-uuid", + writeCharacteristicUuid="write-char-uuid", + readServiceUuid="service-uuid", + readCharacteristicUuid="read-char-uuid", + onReceive=lambda data: None, + ioThread=mockIoThread, + ) + + ble.close() + + self.assertGreater(self.mockRunCoroutineSync.call_count, 1) + self.assertIsNone(ble._onReceive) + + +class TestFindDeviceByAddress(unittest.TestCase): + """Tests for hwIo.ble.findDeviceByAddress""" + + def setUp(self): + """Set up patches for findDeviceByAddress testing.""" + self.scannerPatcher = patch("hwIo.ble.scanner") + self.mockScanner = self.scannerPatcher.start() + + from hwIo.ble import findDeviceByAddress + + self.findDeviceByAddress = findDeviceByAddress + + def tearDown(self): + """Clean up patches.""" + self.scannerPatcher.stop() + + def test_deviceAlreadyInResults(self): + """Test finding device that's already in scanner results.""" + fakeDevice = MagicMock(spec=BLEDevice) + fakeDevice.address = "AA:BB:CC:DD:EE:FF" + fakeDevice.name = "TestDevice" + + self.mockScanner.results.return_value = [fakeDevice] + self.mockScanner.isScanning = False + + result = self.findDeviceByAddress("AA:BB:CC:DD:EE:FF") + + self.assertEqual(result, fakeDevice) + self.mockScanner.start.assert_not_called() + + def test_deviceNotFound(self): + """Test that None is returned when device is not found after timeout.""" + self.mockScanner.results.return_value = [] + self.mockScanner.isScanning = False + + result = self.findDeviceByAddress("AA:BB:CC:DD:EE:FF", timeout=0.1) + + self.assertIsNone(result) + self.mockScanner.start.assert_called_once() + + def test_deviceFoundDuringScan(self): + """Test finding device that appears during scanning.""" + fakeDevice = MagicMock(spec=BLEDevice) + fakeDevice.address = "AA:BB:CC:DD:EE:FF" + fakeDevice.name = "TestDevice" + + callCount = 0 + + def mockResults() -> list[BLEDevice]: + nonlocal callCount + callCount += 1 + if callCount <= 1: + return [] + else: + return [fakeDevice] + + self.mockScanner.results.side_effect = mockResults + self.mockScanner.isScanning = False + + result = self.findDeviceByAddress("AA:BB:CC:DD:EE:FF", timeout=0.5, pollInterval=0.05) + + self.assertEqual(result, fakeDevice) + self.mockScanner.start.assert_called_once() diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index 93821b8bbd8..c75df6b09b4 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -80,6 +80,7 @@ The `scons checkPot` target has also been replaced with `runcheckpot.bat`. Use the individual test commands instead: `runcheckpot.bat`, `rununittests.bat`, `runsystemtests.bat`, `runlint.bat`. (#19606, #19676, @bramd) * Updated Python 3.13.11 to 3.13.12 (#19572, @dpy013) * Added a private `_asyncioEventLoop` module that provides an asyncio event loop running on a background thread for use by NVDA components. (#19816, @bramd) +* Added a new `hwIo.ble` submodule for Bluetooth Low Energy device discovery and I/O, exposing a `Scanner` singleton (with a `deviceDiscovered` extension point), a `Ble` class implementing the `IoBase` contract, and a `findDeviceByAddress` helper. Built on top of [Bleak](https://bleak.readthedocs.io/) and the `_asyncioEventLoop` module. (#19838, @bramd) * Added several functions related to the braille auto-scroll feature. (#18573, @nvdaes): * Added an `autoScroll` method to `braille.handler`. * Added several functions for handling configuration value conversions and updates in `config.conf`: diff --git a/uv.lock b/uv.lock index f8d4f5443f9..1062b5e79e5 100644 --- a/uv.lock +++ b/uv.lock @@ -51,6 +51,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] +[[package]] +name = "bleak" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-bluetooth", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-bluetooth-advertisement", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-bluetooth-genericattributeprofile", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-enumeration", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-radios", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-foundation", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-foundation-collections", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-storage-streams", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/a1/dc0201219279721a9630d04b0176d3f99d4734c460509b514bc02800e492/bleak-3.0.0.tar.gz", hash = "sha256:125f7e25577b0be475d27f2e2ad36515409de6cc15b8bfc0441500cf78d94a60", size = 124152, upload-time = "2026-03-22T16:54:24.179Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/d4/c047f565c483e21710269998b5eacbe5cd21805996758818f2388ce11028/bleak-3.0.0-py3-none-any.whl", hash = "sha256:adca43fd4efe36994b6662114e0ae464298d11d694e96747c78cf079397a4048", size = 144703, upload-time = "2026-03-22T16:54:22.674Z" }, +] + [[package]] name = "boolean-py" version = "5.0" @@ -522,6 +542,7 @@ wheels = [ name = "nvda" source = { editable = "." } dependencies = [ + { name = "bleak", marker = "sys_platform == 'win32'" }, { name = "comtypes", marker = "sys_platform == 'win32'" }, { name = "configobj", marker = "sys_platform == 'win32'" }, { name = "crowdin-api-client", marker = "sys_platform == 'win32'" }, @@ -579,6 +600,7 @@ unit-tests = [ [package.metadata] requires-dist = [ + { name = "bleak", specifier = "==3.0.0" }, { name = "comtypes", specifier = "==1.4.13" }, { name = "configobj", git = "https://github.com/DiffSK/configobj?rev=9c8a0a80c767bf8a3d6493ed01df6c351bddca42" }, { name = "crowdin-api-client", specifier = "==1.24.1" }, @@ -1267,6 +1289,132 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, ] +[[package]] +name = "winrt-runtime" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/dd/acdd527c1d890c8f852cc2af644aa6c160974e66631289420aa871b05e65/winrt_runtime-3.2.1.tar.gz", hash = "sha256:c8dca19e12b234ae6c3dadf1a4d0761b51e708457492c13beb666556958801ea", size = 21721, upload-time = "2025-06-06T14:40:27.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/d4/1a555d8bdcb8b920f8e896232c82901cc0cda6d3e4f92842199ae7dff70a/winrt_runtime-3.2.1-cp313-cp313-win32.whl", hash = "sha256:44e2733bc709b76c554aee6c7fe079443b8306b2e661e82eecfebe8b9d71e4d1", size = 210022, upload-time = "2025-06-06T06:44:11.767Z" }, + { url = "https://files.pythonhosted.org/packages/aa/24/2b6e536ca7745d788dfd17a2ec376fa03a8c7116dc638bb39b035635484f/winrt_runtime-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:3c1fdcaeedeb2920dc3b9039db64089a6093cad2be56a3e64acc938849245a6d", size = 241349, upload-time = "2025-06-06T06:44:12.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/7f/6d72973279e2929b2a71ed94198ad4a5d63ee2936e91a11860bf7b431410/winrt_runtime-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:28f3dab083412625ff4d2b46e81246932e6bebddf67bea7f05e01712f54e6159", size = 415126, upload-time = "2025-06-06T06:44:13.702Z" }, +] + +[[package]] +name = "winrt-windows-devices-bluetooth" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/a0/1c8a0c469abba7112265c6cb52f0090d08a67c103639aee71fc690e614b8/winrt_windows_devices_bluetooth-3.2.1.tar.gz", hash = "sha256:db496d2d92742006d5a052468fc355bf7bb49e795341d695c374746113d74505", size = 23732, upload-time = "2025-06-06T14:41:20.489Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/cc/797516c5c0f8d7f5b680862e0ed7c1087c58aec0bcf57a417fa90f7eb983/winrt_windows_devices_bluetooth-3.2.1-cp313-cp313-win32.whl", hash = "sha256:12b0a16fb36ce0b42243ca81f22a6b53fbb344ed7ea07a6eeec294604f0505e4", size = 105757, upload-time = "2025-06-06T07:00:13.269Z" }, + { url = "https://files.pythonhosted.org/packages/05/6d/f60588846a065e69a2ec5e67c5f85eb45cb7edef2ee8974cd52fa8504de6/winrt_windows_devices_bluetooth-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:6703dfbe444ee22426738830fb305c96a728ea9ccce905acfdf811d81045fdb3", size = 113363, upload-time = "2025-06-06T07:00:14.135Z" }, + { url = "https://files.pythonhosted.org/packages/2c/13/2d3c4762018b26a9f66879676ea15d7551cdbf339c8e8e0c56ea05ea31ef/winrt_windows_devices_bluetooth-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:2cf8a0bfc9103e32dc7237af15f84be06c791f37711984abdca761f6318bbdb2", size = 104722, upload-time = "2025-06-06T07:00:14.999Z" }, +] + +[[package]] +name = "winrt-windows-devices-bluetooth-advertisement" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/fc/7ffe66ca4109b9e994b27c00f3d2d506e6e549e268791f755287ad9106d8/winrt_windows_devices_bluetooth_advertisement-3.2.1.tar.gz", hash = "sha256:0223852a7b7fa5c8dea3c6a93473bd783df4439b1ed938d9871f947933e574cc", size = 16906, upload-time = "2025-06-06T14:41:21.448Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/01/8fc8e57605ea08dd0723c035ed0c2d0435dace2bc80a66d33aecfea49a56/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp313-cp313-win32.whl", hash = "sha256:4122348ea525a914e85615647a0b54ae8b2f42f92cdbf89c5a12eea53ef6ed90", size = 90037, upload-time = "2025-06-06T07:00:25.818Z" }, + { url = "https://files.pythonhosted.org/packages/86/83/503cf815d84c5ba8c8bc61480f32e55579ebf76630163405f7df39aa297b/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:b66410c04b8dae634a7e4b615c3b7f8adda9c7d4d6902bcad5b253da1a684943", size = 95822, upload-time = "2025-06-06T07:00:26.666Z" }, + { url = "https://files.pythonhosted.org/packages/32/13/052be8b6642e6f509b30c194312b37bfee8b6b60ac3bd5ca2968c3ea5b80/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:07af19b1d252ddb9dd3eb2965118bc2b7cabff4dda6e499341b765e5038ca61d", size = 89326, upload-time = "2025-06-06T07:00:27.477Z" }, +] + +[[package]] +name = "winrt-windows-devices-bluetooth-genericattributeprofile" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/21/aeeddc0eccdfbd25e543360b5cc093233e2eab3cdfb53ad3cabae1b5d04d/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1.tar.gz", hash = "sha256:cdf6ddc375e9150d040aca67f5a17c41ceaf13a63f3668f96608bc1d045dde71", size = 38896, upload-time = "2025-06-06T14:41:22.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/93/30b45ce473d1a604908221a1fa035fe8d5e4bb9008e820ae671a21dab94c/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp313-cp313-win32.whl", hash = "sha256:b1879c8dcf46bd2110b9ad4b0b185f4e2a5f95170d014539203a5fee2b2115f0", size = 183342, upload-time = "2025-06-06T07:00:56.16Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3b/eb9d99b82a36002d7885206d00ea34f4a23db69c16c94816434ded728fa3/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d8d89f01e9b6931fb48217847caac3227a0aeb38a5b7782af71c2e7b262ec30", size = 187844, upload-time = "2025-06-06T07:00:57.134Z" }, + { url = "https://files.pythonhosted.org/packages/84/9b/ebbbe9be9a3e640dcfc5f166eb48f2f9d8ce42553f83aa9f4c5dcd9eb5f5/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:4e71207bb89798016b1795bb15daf78afe45529f2939b3b9e78894cfe650b383", size = 184540, upload-time = "2025-06-06T07:00:58.081Z" }, +] + +[[package]] +name = "winrt-windows-devices-enumeration" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/dd/75835bfbd063dffa152109727dedbd80f6e92ea284855f7855d48cdf31c9/winrt_windows_devices_enumeration-3.2.1.tar.gz", hash = "sha256:df316899e39bfc0ffc1f3cb0f5ee54d04e1d167fbbcc1484d2d5121449a935cf", size = 23538, upload-time = "2025-06-06T14:41:26.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/7d/ebd712ab8ccd599c593796fbcd606abe22b5a8e20db134aa87987d67ac0e/winrt_windows_devices_enumeration-3.2.1-cp313-cp313-win32.whl", hash = "sha256:14a71cdcc84f624c209cbb846ed6bd9767a9a9437b2bf26b48ac9a91599da6e9", size = 130276, upload-time = "2025-06-06T07:02:05.178Z" }, + { url = "https://files.pythonhosted.org/packages/70/de/f30daaaa0e6f4edb6bd7ddb3e058bd453c9ad90c032a4545c4d4639338aa/winrt_windows_devices_enumeration-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:6ca40d334734829e178ad46375275c4f7b5d6d2d4fc2e8879690452cbfb36015", size = 141536, upload-time = "2025-06-06T07:02:06.067Z" }, + { url = "https://files.pythonhosted.org/packages/75/4b/9a6aafdc74a085c550641a325be463bf4b811f6f605766c9cd4f4b5c19d2/winrt_windows_devices_enumeration-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:2d14d187f43e4409c7814b7d1693c03a270e77489b710d92fcbbaeca5de260d4", size = 135362, upload-time = "2025-06-06T07:02:06.997Z" }, +] + +[[package]] +name = "winrt-windows-devices-radios" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/02/9704ea359ad8b0d6faa1011f98fb477e8fb6eac5201f39d19e73c2407e7b/winrt_windows_devices_radios-3.2.1.tar.gz", hash = "sha256:4dc9b9d1501846049eb79428d64ec698d6476c27a357999b78a8331072e18a0b", size = 5908, upload-time = "2025-06-06T14:41:44.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/94/c22a14fd424632f3f3c0b25672218db9e8f4ae9e1355e0b148f2fe6015b5/winrt_windows_devices_radios-3.2.1-cp313-cp313-win32.whl", hash = "sha256:ae4a0065927fcd2d10215223f8a46be6fb89bad71cb4edd25dae3d01c137b3a8", size = 38613, upload-time = "2025-06-06T07:08:04.077Z" }, + { url = "https://files.pythonhosted.org/packages/39/c1/24cec0cc228642554b48d436a7617d7162fb952919c55fc26e2d99c310bd/winrt_windows_devices_radios-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:bf1a975f46a2aa271ffea1340be0c7e64985050d07433e701343dddc22a72290", size = 40180, upload-time = "2025-06-06T07:08:04.849Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d3/776453af26e78c0d0c0e1bfa89f86fd81322872f31a3e5dafb344dd47bf2/winrt_windows_devices_radios-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:10b298ed154c5824cea2de174afce1694ed2aabfb58826de814074027ffef96f", size = 36989, upload-time = "2025-06-06T07:08:05.576Z" }, +] + +[[package]] +name = "winrt-windows-foundation" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/55/098ce7ea0679efcc1298b269c48768f010b6c68f90c588f654ec874c8a74/winrt_windows_foundation-3.2.1.tar.gz", hash = "sha256:ad2f1fcaa6c34672df45527d7c533731fdf65b67c4638c2b4aca949f6eec0656", size = 30485, upload-time = "2025-06-06T14:41:53.344Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/71/5e87131e4aecc8546c76b9e190bfe4e1292d028bda3f9dd03b005d19c76c/winrt_windows_foundation-3.2.1-cp313-cp313-win32.whl", hash = "sha256:3998dc58ed50ecbdbabace1cdef3a12920b725e32a5806d648ad3f4829d5ba46", size = 112184, upload-time = "2025-06-06T07:11:04.459Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7f/8d5108461351d4f6017f550af8874e90c14007f9122fa2eab9f9e0e9b4e1/winrt_windows_foundation-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:6e98617c1e46665c7a56ce3f5d28e252798416d1ebfee3201267a644a4e3c479", size = 118672, upload-time = "2025-06-06T07:11:05.55Z" }, + { url = "https://files.pythonhosted.org/packages/44/f5/2edf70922a3d03500dab17121b90d368979bd30016f6dbca0d043f0c71f1/winrt_windows_foundation-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:2a8c1204db5c352f6a563130a5a41d25b887aff7897bb677d4ff0b660315aad4", size = 109673, upload-time = "2025-06-06T07:11:06.398Z" }, +] + +[[package]] +name = "winrt-windows-foundation-collections" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/62/d21e3f1eeb8d47077887bbf0c3882c49277a84d8f98f7c12bda64d498a07/winrt_windows_foundation_collections-3.2.1.tar.gz", hash = "sha256:0eff1ad0d8d763ad17e9e7bbd0c26a62b27215016393c05b09b046d6503ae6d5", size = 16043, upload-time = "2025-06-06T14:41:53.983Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/cd/99ef050d80bea2922fa1ded93e5c250732634095d8bd3595dd808083e5ca/winrt_windows_foundation_collections-3.2.1-cp313-cp313-win32.whl", hash = "sha256:4267a711b63476d36d39227883aeb3fb19ac92b88a9fc9973e66fbce1fd4aed9", size = 60063, upload-time = "2025-06-06T07:11:18.65Z" }, + { url = "https://files.pythonhosted.org/packages/94/93/4f75fd6a4c96f1e9bee198c5dc9a9b57e87a9c38117e1b5e423401886353/winrt_windows_foundation_collections-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:5e12a6e75036ee90484c33e204b85fb6785fcc9e7c8066ad65097301f48cdd10", size = 69057, upload-time = "2025-06-06T07:11:19.446Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/de47ccc390017ec5575e7e7fd9f659ee3747c52049cdb2969b1b538ce947/winrt_windows_foundation_collections-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:34b556255562f1b36d07fba933c2bcd9f0db167fa96727a6cbb4717b152ad7a2", size = 58792, upload-time = "2025-06-06T07:11:20.24Z" }, +] + +[[package]] +name = "winrt-windows-storage-streams" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/50/f4488b07281566e3850fcae1021f0285c9653992f60a915e15567047db63/winrt_windows_storage_streams-3.2.1.tar.gz", hash = "sha256:476f522722751eb0b571bc7802d85a82a3cae8b1cce66061e6e758f525e7b80f", size = 34335, upload-time = "2025-06-06T14:43:23.905Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/d2/24d9f59bdc05e741261d5bec3bcea9a848d57714126a263df840e2b515a8/winrt_windows_storage_streams-3.2.1-cp313-cp313-win32.whl", hash = "sha256:401bb44371720dc43bd1e78662615a2124372e7d5d9d65dfa8f77877bbcb8163", size = 127774, upload-time = "2025-06-06T14:02:04.752Z" }, + { url = "https://files.pythonhosted.org/packages/15/59/601724453b885265c7779d5f8025b043a68447cbc64ceb9149d674d5b724/winrt_windows_storage_streams-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:202c5875606398b8bfaa2a290831458bb55f2196a39c1d4e5fa88a03d65ef915", size = 131827, upload-time = "2025-06-06T14:02:05.601Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c2/a419675a6087c9ea496968c9b7805ef234afa585b7483e2269608a12b044/winrt_windows_storage_streams-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:ca3c5ec0aab60895006bf61053a1aca6418bc7f9a27a34791ba3443b789d230d", size = 128180, upload-time = "2025-06-06T14:02:06.759Z" }, +] + [[package]] name = "wrapt" version = "2.1.2"