diff --git a/.gitignore b/.gitignore index 6207b79..94ede6f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ .directory -.vscode diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..dc647f7 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,34 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "demo", + "type": "debugpy", + "request": "launch", + "program": "demo.py", + "console": "integratedTerminal", + "justMyCode": false, + "preLaunchTask": "Poetry Install" + }, + { + "name": "Python: Pytest", + "type": "debugpy", + "request": "launch", + "module": "pytest", + "args": [ + "-vvv", + "--log-cli-level=0" + ], + "console": "integratedTerminal", + "preLaunchTask": "Poetry Install" + }, + { + "name": "Python: Current File", + "type": "debugpy", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal", + "preLaunchTask": "Poetry Install" + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..7760562 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,41 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Install Poetry", + "type": "process", + "command": "${workspaceFolder}/.venv/bin/python", + "windows": { + "command": "${workspaceFolder}\\.venv\\Scripts\\python.exe" + }, + "args": [ + "-m", + "pip", + "install", + "poetry" + ], + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [] + }, + { + "label": "Poetry Install", + "type": "process", + "command": "${workspaceFolder}/.venv/bin/python", + "windows": { + "command": "${workspaceFolder}\\.venv\\Scripts\\python.exe" + }, + "args": [ + "-m", + "poetry", + "install" + ], + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [], + "dependsOn": "Install Poetry" + } + ] +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ba3d944 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,20 @@ +# Agent Guidelines + +- Never use `getattr` or `setattr`. +- Use type hints. +- Write clean code. If you're writing many `if` statements, you're probably doing it wrong. +- Avoid keyword-only `*` in method/function signatures unless explicitly requested. +- Before you commit, run pre-commit `ruff-format`, then commit and push the changes (use a dedicated branch for each session). If pre-commit returns errors, fix them. For pre-commit to work, `cd` into the current project and activate the environment. +- Ensure git hooks can resolve `python`: run commit/pre-commit commands with the project venv first on `PATH`, e.g. `PATH=\"$(poetry env info -p)/bin:$PATH\" poetry run pre-commit run ruff-format --files ` and `PATH=\"$(poetry env info -p)/bin:$PATH\" git commit -m \"\"`. + +## App Run + GUI Interaction Notes + +- Launch the app demo with `DISPLAY=desktop:0 poetry run python demo.py`. +- For GUI pytests where you want to see windows on the running X server, force the display and Qt platform: + - `DISPLAY=desktop:0 QT_QPA_PLATFORM=xcb poetry run pytest -vv -s` +- Screenshot the X server via a tiny PyQt6 script using `QGuiApplication` + `primaryScreen().grabWindow(0)`. +- Best practice for clicks: + - Ensure window focus (`xdotool windowactivate --sync `). + - Use `--clearmodifiers` and/or explicit `mousedown`/`mouseup`. + - If a button ignores clicks, try small coordinate offsets or absolute screen coords. + - Keyboard fallback: tab to focus, then `Return` or `space`. diff --git a/README.md b/README.md index 7341c7b..3727474 100644 --- a/README.md +++ b/README.md @@ -3,13 +3,13 @@ * This provides an abstraction layer ontop of hwi, such that only bdk is needed from the outside * Supported are - Coldcard, Coldcard Q, Bitbox02, Blockstream Jade, Trezor Safe, Foundation Passport, Keystone, Ledger, Specter DIY + - Blockstream Jade can be connected via USB and Bluetooth * It also provides - AddressTypes, which are the commonly used bitcoin output descriptor templates - seed_tools.derive_spk_provider to derive xpubs from seeds for all AddressTypes (bdk does not support multisig templates currently https://github.com/bitcoindevkit/bdk/issues/1020) - SoftwareSigner which can sign single and multisig PSBTs, this doesn't do any security checks, so only use it on testnet - - HWIQuick to list the connected devices without the need to unlock them (this however only works with all devices after initialization) ### Demo @@ -67,5 +67,3 @@ pre-commit install ```shell pre-commit run --all-files ``` - - diff --git a/bitcoin_usb/device.py b/bitcoin_usb/device.py index 115b4fd..c873961 100644 --- a/bitcoin_usb/device.py +++ b/bitcoin_usb/device.py @@ -12,6 +12,7 @@ from hwilib.devices.bitbox02 import Bitbox02Client, CLINoiseConfig from hwilib.devices.bitbox02_lib import bitbox02 from hwilib.devices.bitbox02_lib.communication import devices as bitbox02devices +from hwilib.devices.jadepy.jade import DEFAULT_BLE_DEVICE_NAME from hwilib.devices.trezor import TrezorClient from hwilib.hwwclient import HardwareWalletClient from hwilib.psbt import PSBT @@ -27,6 +28,7 @@ from bitcoin_usb.dialogs import Worker from bitcoin_usb.i18n import translate +from bitcoin_usb.jade_ble_client import JadeBleClient from bitcoin_usb.util import run_device_task, run_script from .address_types import ( @@ -284,11 +286,22 @@ def write_down_seed_ask_until_success(cls, client: Bitbox02Client) -> bool | Non return False def _init_client(self): - self.client = hwi_commands.get_client( - device_type=self.selected_device["type"], - device_path=self.selected_device["path"], - chain=bdknetwork_to_chain(self.network), - ) + if ( + self.selected_device.get("type") == "jade" + and self.selected_device.get("transport") == "bluetooth" + ): + self.client = JadeBleClient( + device_name=self.selected_device.get("bluetooth_name", DEFAULT_BLE_DEVICE_NAME), + serial_number=self.selected_device.get("bluetooth_serial_number"), + device_address=self.selected_device.get("bluetooth_address"), + chain=bdknetwork_to_chain(self.network), + ) + else: + self.client = hwi_commands.get_client( + device_type=self.selected_device["type"], + device_path=self.selected_device["path"], + chain=bdknetwork_to_chain(self.network), + ) if isinstance(self.client, TrezorClient): self.client.client.refresh_features() diff --git a/bitcoin_usb/dialogs.py b/bitcoin_usb/dialogs.py index 387d7a4..62e446d 100644 --- a/bitcoin_usb/dialogs.py +++ b/bitcoin_usb/dialogs.py @@ -1,21 +1,25 @@ import sys -import time from collections.abc import Callable from functools import partial -from typing import Any, Generic, TypeVar +from typing import Any import bdkpython as bdk -from PyQt6.QtCore import QEventLoop, QObject, QThread, pyqtSignal -from PyQt6.QtGui import QCloseEvent, QGuiApplication +from PyQt6.QtCore import QObject, Qt, QThread, pyqtSignal +from PyQt6.QtGui import QCloseEvent, QGuiApplication, QIcon, QShowEvent from PyQt6.QtWidgets import ( - QApplication, QDialog, + QDialogButtonBox, + QGroupBox, QLabel, QMessageBox, + QProgressBar, QPushButton, + QSizePolicy, QVBoxLayout, ) +from bitcoin_usb.util import get_icon_path + def get_message_box( text: str, icon: QMessageBox.Icon = QMessageBox.Icon.Information, title: str = "" @@ -51,78 +55,87 @@ def run(self): self.error.emit(e) # Emit error if an exception occurs -T = TypeVar("T") - +class DeviceDialog(QDialog): + _detached_scan_threads: set[QThread] = set() + _usb_icon_path = get_icon_path("bi--usb-symbol.svg") + _bluetooth_icon_path = get_icon_path("bi--bluetooth.svg") -class ThreadedWaitingDialog(QDialog, Generic[T]): def __init__( self, - func: Callable[[], T], - *args, - title="Processing...", - message="Please wait, processing operation...", - **kwargs, + parent, + network: bdk.Network, + usb_scan_callback: Callable[[], list[dict[str, Any]]], + bluetooth_scan_callback: Callable[[], list[dict[str, Any]]] | None = None, + install_udev_callback: Callable[[], None] | None = None, + autoselect_if_1_device: bool = False, ): - super().__init__() - self.setWindowTitle(title) - self.setModal(True) - - self._layout = QVBoxLayout(self) - self.label = QLabel(message) - self._layout.addWidget(self.label) - - # Setup worker and thread - self.worker = Worker(func, *args, **kwargs) - self._thread = QThread() - self.worker.moveToThread(self._thread) - self.worker.finished.connect(self.handle_func_result) - self.worker.error.connect(self.handle_func_error) # Connect error signal - self._thread.started.connect(self.worker.run) - - self.loop = QEventLoop() # Event loop to block for synchronous execution - self.exception = None # To store an exception, if it occurs - - def handle_func_result(self, func_result: T): - self.func_result = func_result - if self.loop.isRunning(): - self.loop.exit() # Exit the loop only if it's running - - def handle_func_error(self, exception): - self.exception = exception - if self.loop.isRunning(): - self.loop.exit() # Exit the loop when an error is encountered - - def get_result(self) -> T: - self.show() # Show the dialog - self._thread.start() # Start the thread - self.loop.exec() # Block here until the operation finishes or errors out - self.close() # Close the dialog - if self.exception: - raise self.exception # Re-raise the exception after closing the dialog - return self.func_result - - def closeEvent(self, a0: QCloseEvent | None) -> None: - if self._thread.isRunning(): - self._thread.quit() - self._thread.wait() - super().closeEvent(a0) - - -class DeviceDialog(QDialog): - def __init__(self, parent, devices: list[dict[str, Any]], network: bdk.Network): super().__init__(parent) self.setWindowTitle(self.tr("Select the detected device")) self._layout = QVBoxLayout(self) self.setModal(True) - # Creating a button for each device - for device in devices: - button = QPushButton(f"{device.get('type', '')} - {device.get('model', '')}", self) - button.clicked.connect(partial(self.select_device, device)) - self._layout.addWidget(button) - - self.selected_device: dict[str, Any] | None = None self.network = network + self.usb_scan_callback = usb_scan_callback + self.bluetooth_scan_callback = bluetooth_scan_callback + self.install_udev_callback = install_udev_callback + self.autoselect_if_1_device = autoselect_if_1_device + self.selected_device: dict[str, Any] | None = None + self._devices_by_key: dict[tuple[str, str, str], dict[str, Any]] = {} + self._scan_thread: QThread | None = None + self._scan_worker: Worker | None = None + self._has_auto_scanned_on_open = False + self._has_completed_usb_scan = False + self._scan_finished_message = "" + self.usb_icon = self._load_icon(self._usb_icon_path) + self.bluetooth_icon = self._load_icon(self._bluetooth_icon_path) + + self.instructions_label = QLabel(self) + self.instructions_label.setWordWrap(True) + self.instructions_label.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) + self._set_instructions_message("") + self._layout.addWidget(self.instructions_label) + + self.progress = QProgressBar(self) + self.progress.setRange(0, 0) # indeterminate spinner + self.progress.hide() + self._layout.addWidget(self.progress) + + self.devices_group = QGroupBox(self.tr("Detected devices"), self) + self.devices_group.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self.devices_layout = QVBoxLayout(self.devices_group) + self._layout.addWidget(self.devices_group, stretch=1) + + self.actions_bar = QDialogButtonBox(self) + self.usb_scan_button = self.actions_bar.addButton( + self.tr("Scan USB devices"), QDialogButtonBox.ButtonRole.ActionRole + ) + self.usb_scan_button.setIcon(self.usb_icon) + self.usb_scan_button.clicked.connect(self.scan_usb_devices) + self.usb_scan_button.setAutoDefault(True) + self.usb_scan_button.setDefault(True) + + self.bluetooth_scan_button: QPushButton | None = None + if self.bluetooth_scan_callback: + self.bluetooth_scan_button = self.actions_bar.addButton( + self.tr("Scan Bluetooth devices"), QDialogButtonBox.ButtonRole.ActionRole + ) + self.bluetooth_scan_button.setIcon(self.bluetooth_icon) + self.bluetooth_scan_button.clicked.connect(self.scan_for_bluetooth_devices) + self.bluetooth_scan_button.setAutoDefault(False) + + self.install_udev_button: QPushButton | None = None + if self.install_udev_callback and sys.platform.startswith("linux"): + self.install_udev_button = self.actions_bar.addButton( + self.tr("Install udev rules"), QDialogButtonBox.ButtonRole.ActionRole + ) + self.install_udev_button.clicked.connect(self.install_udev_callback) + self.install_udev_button.setAutoDefault(False) + self.install_udev_button.setVisible(False) + + self.cancel_button = self.actions_bar.addButton(QDialogButtonBox.StandardButton.Cancel) + self.actions_bar.rejected.connect(self.reject) + self.cancel_button.setAutoDefault(False) + self._layout.addWidget(self.actions_bar) # ensure the dialog has its “natural” size self.adjustSize() @@ -136,25 +149,272 @@ def __init__(self, parent, devices: list[dict[str, Any]], network: bdk.Network): fg.moveCenter(screen_center) self.move(fg.topLeft()) + # Scan starts automatically when the dialog is shown. + def select_device(self, device: dict[str, Any]): self.selected_device = device self.accept() - def get_selected_device(self) -> dict[str, Any] | None: - return self.selected_device - + @staticmethod + def _load_icon(icon_path: str) -> QIcon: + return QIcon(icon_path) + + def _transport_icon(self, transport: str) -> QIcon: + if transport == "bluetooth": + return self.bluetooth_icon + return self.usb_icon + + def _button_text(self, device: dict[str, Any]) -> str: + device_type = str(device.get("type", "")).strip() + model = str(device.get("model", "")).strip() + + label = device_type + if model and model != device_type: + label = f"{device_type} - {model}" + + if device.get("transport") != "bluetooth": + return label + + details: list[str] = [] + if bluetooth_name := str(device.get("bluetooth_name", "")).strip(): + details.append(bluetooth_name) + if bluetooth_serial := str(device.get("bluetooth_serial_number", "")).strip(): + details.append(self.tr("SN {serial}").format(serial=bluetooth_serial)) + if bluetooth_address := str(device.get("bluetooth_address", "")).strip(): + details.append(bluetooth_address) + + details_text = f" - {details[0]}" if details else "" + return f"{label} (Bluetooth){details_text}" + + def _device_key(self, device: dict[str, Any]) -> tuple[str, str, str]: + return ( + str(device.get("type", "")), + str(device.get("path", "")), + str(device.get("transport", "usb")), + ) + + def _set_scanning(self, scanning: bool): + self.progress.setVisible(scanning) + self.usb_scan_button.setEnabled(not scanning) + if self.bluetooth_scan_button: + self.bluetooth_scan_button.setEnabled(not scanning) + if self.install_udev_button: + if scanning: + self.install_udev_button.setVisible(False) + else: + self._update_install_udev_button_visibility() + + def _instructions_base_text(self) -> str: + return self.tr( + "1. Connect your hardware signer\n2. Click Scan\n3. Unlock the device\n4. Select the device" + ) + + def _set_instructions_message(self, hint: str) -> None: + text = self._instructions_base_text() + if hint: + text = f"{text}\n\n{hint}" + self.instructions_label.setText(text) + + def _render_devices(self): + while self.devices_layout.count(): + item = self.devices_layout.takeAt(0) + if item is None: + continue + widget = item.widget() + if widget: + widget.setHidden(True) + widget.setParent(None) + + devices = list(self._devices_by_key.values()) + devices.sort( + key=lambda d: ( + str(d.get("transport", "usb")), + str(d.get("type", "")), + str(d.get("model", "")), + str(d.get("bluetooth_name", "")), + str(d.get("bluetooth_address", "")), + str(d.get("path", "")), + ) + ) + if not devices: + empty_label = QLabel(self.tr("No devices found"), self.devices_group) + empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.devices_layout.addWidget(empty_label) + return -if __name__ == "__main__": + for device in devices: + button = QPushButton(self._button_text(device), self) + button.setIcon(self._transport_icon(str(device.get("transport", "usb")))) + button.clicked.connect(partial(self.select_device, device)) + button.setAutoDefault(False) + self.devices_layout.addWidget(button) - def main(): - QApplication(sys.argv) + def _replace_devices_for_transport(self, transport: str, devices: list[dict[str, Any]]) -> None: + self._devices_by_key = { + k: v for k, v in self._devices_by_key.items() if str(v.get("transport", "usb")) != transport + } + for device in devices: + normalized = dict(device) + normalized["transport"] = str(normalized.get("transport", transport)) + self._devices_by_key[self._device_key(normalized)] = normalized + + def _usb_device_count(self) -> int: + return sum( + 1 for device in self._devices_by_key.values() if str(device.get("transport", "usb")) == "usb" + ) + + def _update_install_udev_button_visibility(self) -> None: + if not self.install_udev_button: + return + show_button = self._has_completed_usb_scan and self._usb_device_count() == 0 + self.install_udev_button.setVisible(show_button) + + def _empty_state_hint_text(self) -> str: + options = [self.tr("scan USB devices")] + if self.bluetooth_scan_callback: + options.append(self.tr("scan Bluetooth devices")) + if self.install_udev_button and self.install_udev_button.isVisible(): + options.append(self.tr("install udev rules")) + + if len(options) == 1: + action_text = options[0] + elif len(options) == 2: + action_text = f"{options[0]} {self.tr('or')} {options[1]}" + else: + action_text = ", ".join(options[:-1]) + f", {self.tr('or')} {options[-1]}" + + return self.tr("Try to {actions}.").format(actions=action_text) + + def _on_scan_result(self, source: str, result: object): + devices = result if isinstance(result, list) else [] + transport = "bluetooth" if source == "bluetooth" else "usb" + self._replace_devices_for_transport(transport=transport, devices=devices) + if transport == "usb": + self._has_completed_usb_scan = True + self._update_install_udev_button_visibility() + self._render_devices() + + total = len(self._devices_by_key) + if total and self.autoselect_if_1_device and total == 1: + self._on_scan_finished() + self.selected_device = next(iter(self._devices_by_key.values())) + self.accept() + return + + self._on_scan_finished() + + def _on_scan_error(self, source: str, exception: Exception): + if source == "usb": + self._has_completed_usb_scan = True + self._update_install_udev_button_visibility() + get_message_box( + text=self.tr("Device scan failed: {error}").format(error=str(exception)), + title=self.tr("Device scan"), + icon=QMessageBox.Icon.Critical, + ).exec() + self._on_scan_finished() + + def _on_scan_finished(self): + self._set_scanning(False) + self._set_instructions_message(self._scan_finished_message) + self._scan_finished_message = "" + self.usb_scan_button.setDefault(True) + self.usb_scan_button.setFocus() + self._stop_scan(wait_timeout_ms=50) + + @classmethod + def _track_detached_scan_thread(cls, thread: QThread, worker: Worker | None) -> None: + cls._detached_scan_threads.add(thread) + + def _cleanup_detached_thread() -> None: + if worker: + worker.deleteLater() + thread.deleteLater() + cls._detached_scan_threads.discard(thread) + + thread.finished.connect(_cleanup_detached_thread) + + @staticmethod + def _disconnect_scan_worker(worker: Worker) -> None: + for signal in (worker.finished, worker.error): + try: + signal.disconnect() + except TypeError: + # No connections remain. + pass + + def _stop_scan(self, wait_timeout_ms: int) -> None: + worker = self._scan_worker + thread = self._scan_thread + self._scan_worker = None + self._scan_thread = None + + if not thread: + return + + if worker: + self._disconnect_scan_worker(worker) + + if thread.isRunning(): + thread.requestInterruption() + thread.quit() + if wait_timeout_ms > 0 and thread.wait(wait_timeout_ms): + if worker: + worker.deleteLater() + thread.deleteLater() + return + thread.setParent(None) + self._track_detached_scan_thread(thread=thread, worker=worker) + return + + if worker: + worker.deleteLater() + thread.deleteLater() + + def _start_scan( + self, scan_fn: Callable[[], list[dict[str, Any]]], source: str, message: str, finished_message: str + ) -> None: + if self._scan_thread and self._scan_thread.isRunning(): + return + + self._set_scanning(True) + self._scan_finished_message = finished_message + self._scan_worker = Worker(scan_fn) + self._scan_thread = QThread(self) + self._scan_worker.moveToThread(self._scan_thread) + self._scan_thread.started.connect(self._scan_worker.run) + self._scan_worker.finished.connect(lambda result: self._on_scan_result(source, result)) + self._scan_worker.error.connect(lambda exception: self._on_scan_error(source, exception)) + self._scan_thread.start() + self._set_instructions_message(message) + + def scan_usb_devices(self): + self._start_scan( + scan_fn=self.usb_scan_callback, + source="usb", + message=self.tr("Unlock your hardware signer"), + finished_message="", + ) + + def scan_for_bluetooth_devices(self): + if not self.bluetooth_scan_callback: + return + self._start_scan( + scan_fn=self.bluetooth_scan_callback, + source="bluetooth", + message=self.tr("Scanning for compatible Bluetooth hardware signers."), + finished_message="", + ) - def f(): - time.sleep(5) - return {"res": "res"} + def get_selected_device(self) -> dict[str, Any] | None: + return self.selected_device - manager = ThreadedWaitingDialog(f, title="Operation In Progress", message="Processing data...") - func_result = manager.get_result() # Get func_result directly via method - print("Operation completed with func_result:", func_result) + def closeEvent(self, a0: QCloseEvent | None) -> None: + self._stop_scan(wait_timeout_ms=0) + super().closeEvent(a0) - main() + def showEvent(self, a0: QShowEvent | None) -> None: + super().showEvent(a0) + if not self._has_auto_scanned_on_open: + self._has_auto_scanned_on_open = True + self.scan_usb_devices() diff --git a/bitcoin_usb/hwi_quick.py b/bitcoin_usb/hwi_quick.py deleted file mode 100644 index 5463e42..0000000 --- a/bitcoin_usb/hwi_quick.py +++ /dev/null @@ -1,135 +0,0 @@ -import importlib -import logging -from typing import Any -from unittest.mock import MagicMock, patch - -import bdkpython as bdk -import hwilib.commands as hwi_commands - -from .device import bdknetwork_to_chain - -logger = logging.getLogger(__name__) - - -class HWIQuick: - """The issue with hwilib.commands.enumerate is that it needs to unlock all connected devices. - However for simply listing the devices (without fingerprint), this isnt necessary. - - Even worse, if multiple USB devices are connected and the user is expecting to use - device A, and hwi tries to init the client for device B, the user is confused, because he - just sees a blocking UI and doesnt notice device B. - - In this class we therefore use hwilib, but mock the Client class, such that it doesnt - need to unlock the device and just returns dummy values. - - To really access the device only "type" and "path" are important, which HWIQuick does get: - hwi_commands.get_client( - device_type=self.selected_device["type"], - device_path=self.selected_device["path"], - chain=bdknetwork_to_chain(self.network), - ). - """ - - def __init__(self, network: bdk.Network) -> None: - self.network = network - - @staticmethod - def mock_bitbox02_enumerate(): - from hwilib.devices.bitbox02_lib.communication.bitbox_api_protocol import ( - BitBox02Edition, - ) - - imported_bitbox02 = importlib.import_module("hwilib.devices.bitbox02_lib.communication.devices") - - result = [] - for device_info in imported_bitbox02.get_any_bitbox02s(): - path = device_info["path"].decode() - d_data: dict[str, object] = {} - - d_data.update( - { - "type": "bitbox02", - "path": path, - "model": { - BitBox02Edition.MULTI: "bitbox02_multi", - BitBox02Edition.BTCONLY: "bitbox02_btconly", - }[BitBox02Edition.BTCONLY], - "needs_pin_sent": False, - "needs_passphrase_sent": False, - } - ) - result.append(d_data) - return result - - @patch("hwilib.devices.jade.JadeClient", new_callable=MagicMock) - @patch("hwilib.devices.coldcard.ColdcardClient", new_callable=MagicMock) - @patch("hwilib.devices.keepkey.KeepkeyClient") - @patch("hwilib.devices.digitalbitbox.send_encrypt") - @patch("hwilib.devices.digitalbitbox.DigitalbitboxClient") - @patch("hwilib.devices.ledger.LedgerClient") - @patch("hwilib.devices.trezor.TrezorClient") - @patch("hwilib.devices.bitbox02.enumerate") - def enumerate( - self, - bitbox02_enumerate, - mock_trezor_client, - mock_ledger_client, - mock_digitalbitbox_client, - mock_digitalbitbox_send_encrypt, - mock_keepkey_client, - mock_coldcard_client, - mock_jade_client, - ) -> list[dict[str, Any]]: - "This enumerates the devices without unlocking them. It cannot retrieve the fingerprint" - allow_emulators = False - devices = [] - - mock_jade_instance = mock_jade_client.return_value - mock_jade_instance.get_master_fingerprint.return_value = "mocked result" - - mock_coldcard_instance = mock_coldcard_client.return_value - mock_coldcard_instance.get_master_fingerprint.return_value = "mocked result" - - # mock_keepkey_client - mock_client_instance = mock_keepkey_client.return_value - mock_client_instance.get_master_fingerprint.return_value = "mocked result" - mock_client_instance.client = MagicMock( - refresh_features=MagicMock(), - features=MagicMock( - vendor="keepkey", - label="Mock KeepKey", - unlocked=False, - initialized=True, - pin_protection=True, - passphrase_protection=False, - ), - ) - - # Configure the mock client - mock_client_instance = mock_digitalbitbox_client.return_value - mock_client_instance.get_master_fingerprint.return_value = "mocked result" - mock_digitalbitbox_send_encrypt.return_value = {"fingerprint": "mocked result"} - - # Configure the mock LedgerClient - mock_client_instance = mock_ledger_client.return_value - mock_client_instance.get_master_fingerprint.return_value = MagicMock(hex=lambda: "mocked result") - - # Setup the mock TrezorClient - mock_client_instance = mock_trezor_client.return_value - mock_client_instance.get_master_fingerprint.return_value = MagicMock(hex=lambda: "mocked result") - mock_client_instance.client.features = MagicMock( - vendor="trezor", - model="", - label="Trezor", - unlocked=False, - pin_protection=True, - passphrase_protection=False, - initialized=True, - ) - - bitbox02_enumerate.return_value = self.mock_bitbox02_enumerate() - - devices = hwi_commands.enumerate( - allow_emulators=allow_emulators, chain=bdknetwork_to_chain(self.network) - ) - return devices diff --git a/bitcoin_usb/icons/bi--bluetooth.svg b/bitcoin_usb/icons/bi--bluetooth.svg new file mode 100644 index 0000000..9fd98d9 --- /dev/null +++ b/bitcoin_usb/icons/bi--bluetooth.svg @@ -0,0 +1,41 @@ + + + + + + diff --git a/bitcoin_usb/icons/bi--usb-symbol.svg b/bitcoin_usb/icons/bi--usb-symbol.svg new file mode 100644 index 0000000..b1d7942 --- /dev/null +++ b/bitcoin_usb/icons/bi--usb-symbol.svg @@ -0,0 +1,3 @@ + + + diff --git a/bitcoin_usb/jade_ble_client.py b/bitcoin_usb/jade_ble_client.py new file mode 100644 index 0000000..d2f71b7 --- /dev/null +++ b/bitcoin_usb/jade_ble_client.py @@ -0,0 +1,447 @@ +import asyncio +import collections +import os +import platform +import re +import shutil +import subprocess +from collections.abc import Iterator +from contextlib import contextmanager +from contextvars import ContextVar, Token +from typing import Any + +import semver +from bleak import BleakScanner +from hwilib.common import Chain +from hwilib.devices.jade import HAS_NETWORKING, JadeClient +from hwilib.devices.jadepy import jade as hwi_jade_module +from hwilib.devices.jadepy.jade import DEFAULT_BLE_DEVICE_NAME, DEFAULT_BLE_SCAN_TIMEOUT, JadeAPI +from hwilib.devices.jadepy.jade_error import JadeError +from hwilib.errors import ActionCanceledError, DeviceNotReadyError +from hwilib.hwwclient import HardwareWalletClient +from jadepy import jade_ble as jade_ble_module +from jadepy.jade_ble import JadeBleImpl as BlockstreamJadeBleImpl + +DEFAULT_MAX_AUTH_ATTEMPTS = 3 +DEFAULT_DISCOVERY_SCAN_TIMEOUT_SECONDS = 6.0 +DEFAULT_BLE_CONNECT_TIMEOUT_SECONDS = 15.0 +DEFAULT_BLE_GATT_OPERATION_TIMEOUT_SECONDS = 10.0 +_IS_BT_DEVICE_PATCHED = False +_ORIGINAL_JADEPY_SUBPROCESS_RUN = jade_ble_module.subprocess.run +# Temporary per-call channel to pass a preferred BLE MAC address into the custom +# BLE transport implementation created inside JadeAPI.create_ble(...). +# We use ContextVar instead of a class/global mutable field so concurrent +# connection attempts cannot overwrite each other's preferred address. +_PREFERRED_BLE_ADDRESS: ContextVar[str | None] = ContextVar("_PREFERRED_BLE_ADDRESS", default=None) + + +@contextmanager +def _preferred_ble_address(address: str | None) -> Iterator[None]: + # set() returns a token representing the previous value for this context. + # reset(token) restores that value, which keeps state scoped to this block. + token: Token[str | None] = _PREFERRED_BLE_ADDRESS.set(address) + try: + yield + finally: + _PREFERRED_BLE_ADDRESS.reset(token) + + +def _extract_jade_serial_number(device_name: str) -> str | None: + match = re.match( + rf"^{re.escape(DEFAULT_BLE_DEVICE_NAME)}(?:[\s_-]+(?P[A-Za-z0-9]+))?$", + device_name, + ) + if not match: + return None + return match.groupdict().get("serial") + + +def discover_jade_ble_devices( + scan_timeout: float = DEFAULT_DISCOVERY_SCAN_TIMEOUT_SECONDS, +) -> list[dict[str, Any]]: + devices = asyncio.run(BleakScanner.discover(timeout=max(1.0, scan_timeout))) + discovered: list[dict[str, Any]] = [] + seen_addresses: set[str] = set() + + for dev in devices: + name = (dev.name or "").strip() + if not name.startswith(DEFAULT_BLE_DEVICE_NAME): + continue + + address = str(dev.address) + if address in seen_addresses: + continue + seen_addresses.add(address) + + discovered.append( + { + "type": "jade", + "model": "jade_ble", + "path": f"ble:{address}", + "needs_pin_sent": False, + "needs_passphrase_sent": False, + "transport": "bluetooth", + "bluetooth_name": name, + "bluetooth_address": address, + "bluetooth_serial_number": _extract_jade_serial_number(name), + } + ) + + return discovered + + +def _patch_missing_bt_device_command() -> None: + global _IS_BT_DEVICE_PATCHED + if _IS_BT_DEVICE_PATCHED or platform.system() != "Linux" or shutil.which("bt-device"): + return + + def _safe_subprocess_run(command: Any, *args: Any, **kwargs: Any): + # jadepy tries to run this cleanup command unconditionally on Linux. + # Some distros do not ship bt-device, so treat it as a no-op. + if isinstance(command, str) and command.strip().startswith("bt-device --remove"): + # bt-device is optional and absent on many Linux distros; skip this cleanup call. + return subprocess.CompletedProcess(command, 0) + return _ORIGINAL_JADEPY_SUBPROCESS_RUN(command, *args, **kwargs) + + jade_ble_module.subprocess.run = _safe_subprocess_run + _IS_BT_DEVICE_PATCHED = True + + +class CompatibleJadeBleImpl(BlockstreamJadeBleImpl): + """ + Compatibility wrapper around `jadepy`'s BLE transport. + + Why this class exists: + - It allows selecting a specific BLE address (`preferred_ble_address`) so we + can connect to the exact Jade found during discovery instead of re-scanning + and potentially picking the wrong device. + - It preserves behavior on Linux systems without `bt-device` via the + `_patch_missing_bt_device_command` shim. + - It keeps BLE connection behavior compatible across `bleak` versions + (notably disconnection callback handling). + + Where it is used: + - `JadeBleClient.__init__` patches `hwilib.devices.jadepy.jade.JadeBleImpl` + to this class before calling `JadeAPI.create_ble(...)`. + - `JadeAPI.create_ble(...)` then instantiates this class internally. + """ + + def __init__( + self, + device_name: str, + serial_number: str | None, + scan_timeout: int, + loop: asyncio.AbstractEventLoop | None, + ) -> None: + super().__init__(device_name, serial_number, scan_timeout, loop=loop) + # JadeAPI.create_ble(...) instantiates this class internally. Reading the + # ContextVar here is how we inject a one-off preferred BLE address into + # that internal object without changing external library signatures. + self.preferred_ble_address = _PREFERRED_BLE_ADDRESS.get() + self.client: Any | None = None + self.inputstream: Any | None = None + self.rx_char_handle: int | None = None + self.write_task: asyncio.Task[Any] | None = None + self.connect_timeout_seconds = DEFAULT_BLE_CONNECT_TIMEOUT_SECONDS + self.gatt_operation_timeout_seconds = DEFAULT_BLE_GATT_OPERATION_TIMEOUT_SECONDS + + async def _await_ble_operation( + self, + operation: Any, + timeout_seconds: float, + operation_name: str, + ) -> Any: + try: + return await asyncio.wait_for(operation, timeout=timeout_seconds) + except asyncio.TimeoutError as e: + raise JadeError( + 2, + f"BLE operation timed out: {operation_name}", + f"Timed out after {timeout_seconds:.1f}s", + ) from e + + async def _connect_impl(self) -> None: + assert self.client is None + + # Incoming notifications are buffered here and consumed by _input_stream(). + inbufs: collections.deque[bytes] = collections.deque() + + async def _input_stream(): + # JadeAPI expects a byte-stream-like async iterator. + while self.client is not None: + while inbufs: + buf = inbufs.popleft() + for b in buf: + yield b + await asyncio.sleep(0.01) + self.inputstream = None + + self.inputstream = _input_stream() + + # Use the caller-selected BLE address when present; otherwise fall back to scanning. + device_mac = self.preferred_ble_address + full_name = self.device_name + if not device_mac: + while not device_mac and self.scan_timeout > 0: + jade_ble_module.logger.info(f"Scanning, timeout = {self.scan_timeout}s") + scan_time = min(2, self.scan_timeout) + self.scan_timeout -= scan_time + + devices = await self._await_ble_operation( + BleakScanner.discover(timeout=scan_time), + timeout_seconds=float(scan_time) + self.gatt_operation_timeout_seconds, + operation_name="discover", + ) + for dev in devices: + jade_ble_module.logger.debug(f"Seen: {dev.name}") + if ( + dev.name + and dev.name.startswith(self.device_name) + and (self.serial_number is None or dev.name.endswith(self.serial_number)) + ): + device_mac = dev.address + full_name = dev.name + + if not device_mac: + raise JadeError( + 1, + "Unable to locate BLE device", + f"Device name: {self.device_name}, Serial number: {self.serial_number or ''}", + ) + + if platform.system() == "Linux": + # Remove stale BlueZ pairing state if present. Missing bt-device is handled by the patch above. + command = f'bt-device --remove "{device_mac}"' + jade_ble_module.subprocess.run(command, shell=True, stdout=subprocess.DEVNULL) + + def _disconnection_handler(client: Any) -> None: + assert client == self.client + self.client = None + if self.write_task: + self.write_task.cancel() + self.write_task = None + + connected = False + attempts_remaining = 5 + client = None + needs_set_disconnection_callback = False + # Bleak connect can fail transiently; retry a few times before giving up. + while not connected: + try: + attempts_remaining -= 1 + try: + client = jade_ble_module.bleak.BleakClient( + device_mac, disconnected_callback=_disconnection_handler + ) + except TypeError: + client = jade_ble_module.bleak.BleakClient(device_mac) + needs_set_disconnection_callback = True + + jade_ble_module.logger.info(f"Connecting to: {full_name} ({device_mac})") + await self._await_ble_operation( + client.connect(), + timeout_seconds=self.connect_timeout_seconds, + operation_name="connect", + ) + connected = client.is_connected + jade_ble_module.logger.info(f"Connected: {connected}") + except Exception as e: + jade_ble_module.logger.warning(f"BLE connection exception: {e}") + if not attempts_remaining: + jade_ble_module.logger.warning("Exhausted retries - BLE connection failed") + raise JadeError( + 2, + "Unable to connect to BLE device", + f"Device name: {self.device_name}, Serial number: {self.serial_number or ''}", + ) from e + + if client is None: + raise JadeError( + 2, + "Unable to connect to BLE device", + f"Device name: {self.device_name}, Serial number: {self.serial_number or ''}", + ) + + connected_client = client + # Probe services/chars/descriptors up front to make sure handles are ready. + for service in connected_client.services: + for char in service.characteristics: + if char.uuid == BlockstreamJadeBleImpl.IO_RX_CHAR_UUID: + jade_ble_module.logger.debug(f"Found RX characteristic - handle: {char.handle}") + self.rx_char_handle = char.handle + + if "read" in char.properties: + await self._await_ble_operation( + connected_client.read_gatt_char(char.uuid), + timeout_seconds=self.gatt_operation_timeout_seconds, + operation_name="read_gatt_char", + ) + + for descriptor in char.descriptors: + await self._await_ble_operation( + connected_client.read_gatt_descriptor(descriptor.handle), + timeout_seconds=self.gatt_operation_timeout_seconds, + operation_name="read_gatt_descriptor", + ) + + def _notification_handler(sender: Any, data: Any) -> None: + # bleak may pass sender either as an int handle or as a characteristic object. + sender_handle = -1 + if isinstance(sender, int): + sender_handle = sender + else: + try: + sender_handle = int(sender.handle) + except Exception: + return + + if sender_handle != self.rx_char_handle: + return + inbufs.append(bytes(data)) + + assert self.rx_char_handle + await self._await_ble_operation( + connected_client.start_notify(self.rx_char_handle, _notification_handler), + timeout_seconds=self.gatt_operation_timeout_seconds, + operation_name="start_notify", + ) + + if needs_set_disconnection_callback: + connected_client.set_disconnected_callback(_disconnection_handler) + + self.client = connected_client + + async def _disconnect_impl(self) -> None: + try: + if self.client is not None and self.client.is_connected: + if self.rx_char_handle: + await self._await_ble_operation( + self.client.stop_notify(self.rx_char_handle), + timeout_seconds=self.gatt_operation_timeout_seconds, + operation_name="stop_notify", + ) + await self._await_ble_operation( + self.client.disconnect(), + timeout_seconds=self.gatt_operation_timeout_seconds, + operation_name="disconnect", + ) + except Exception as err: + jade_ble_module.logger.warning(f"Exception when disconnecting ble: {err}") + + self.rx_char_handle = None + self.client = None + if self.write_task: + self.write_task.cancel() + self.write_task = None + + +class JadeBleClient(JadeClient): + """ + HWI Jade client variant that connects over Bluetooth LE. + + Why this class exists: + - Upstream `JadeClient` expects transport setup from HWI, but this project + needs explicit BLE-device selection from GUI discovery results. + - It injects `CompatibleJadeBleImpl` so `JadeAPI.create_ble(...)` uses the + custom transport behavior defined in this module. + - It owns a dedicated asyncio loop for BLE operations and performs the Jade + firmware/auth initialization sequence used by the rest of `USBDevice`. + + Where it is used: + - `bitcoin_usb/device.py` instantiates `JadeBleClient` when a selected + device has `transport == "bluetooth"` and `type == "jade"`. + """ + + def __init__( + self, + device_name: str, + serial_number: str | None, + device_address: str | None = None, + password: str | None = None, + expert: bool = False, + chain: Chain = Chain.MAIN, + scan_timeout: int = DEFAULT_BLE_SCAN_TIMEOUT, + max_auth_attempts: int = DEFAULT_MAX_AUTH_ATTEMPTS, + ) -> None: + _patch_missing_bt_device_command() + hwi_jade_module_any: Any = hwi_jade_module + # Force HWI's jade module to use our compatible BLE transport implementation. + hwi_jade_module_any.JadeBleImpl = CompatibleJadeBleImpl + + path = f"ble:{device_name}:{serial_number or ''}" + HardwareWalletClient.__init__(self, path, password, expert, chain) + self.jade: JadeAPI | None = None + + # Keep BLE traffic on a dedicated loop so this client can run independently. + self._ble_loop = asyncio.new_event_loop() + try: + # Scope the preferred address override to object construction only. + # CompatibleJadeBleImpl.__init__ reads it during create_ble(...), then + # the context manager restores the previous value immediately. + with _preferred_ble_address(device_address): + self.jade = JadeAPI.create_ble( + device_name=device_name, + serial_number=serial_number, + scan_timeout=scan_timeout, + loop=self._ble_loop, + ) + self.jade.connect() + self._initialize_device(max_auth_attempts=max_auth_attempts) + except Exception: + self._disconnect_and_close_loop() + raise + + def _initialize_device(self, max_auth_attempts: int) -> None: + assert self.jade is not None + + # Validate firmware/version and current wallet state before doing auth. + verinfo = self.jade.get_version_info() + self.fw_version = semver.parse_version_info(verinfo["JADE_VERSION"]) + uninitialized = verinfo["JADE_STATE"] not in ["READY", "TEMP"] + + if self.MIN_SUPPORTED_FW_VERSION > self.fw_version.finalize_version(): + raise DeviceNotReadyError( + f"Jade fw version: {self.fw_version} - minimum required version: " + f"{self.MIN_SUPPORTED_FW_VERSION}. Please update using a Blockstream Green companion app" + ) + + if uninitialized and not HAS_NETWORKING: + raise DeviceNotReadyError( + 'Use "Recovery Phrase Login" or "QR PIN Unlock" feature on Jade hw to access wallet' + ) + + self.jade.add_entropy(os.urandom(32)) + + # Auth may fail/cancel repeatedly; cap retries to avoid infinite loops. + failed_attempts = 0 + while True: + try: + authenticated = self.jade.auth_user(self._network()) + except JadeError as e: + if e.code == JadeError.USER_CANCELLED: + raise ActionCanceledError( + "Jade connection/authentication was canceled by the user" + ) from e + raise + + if authenticated: + return + + failed_attempts += 1 + if failed_attempts >= max_auth_attempts: + raise ActionCanceledError("Jade connection/authentication was denied or failed repeatedly") + + def _disconnect_and_close_loop(self) -> None: + # Best-effort shutdown: disconnect device first, then close event loop. + if self.jade is not None: + try: + self.jade.disconnect() + except Exception: + pass + finally: + self.jade = None + if not self._ble_loop.is_closed(): + self._ble_loop.close() + + def close(self) -> None: + self._disconnect_and_close_loop() diff --git a/bitcoin_usb/usb_gui.py b/bitcoin_usb/usb_gui.py index 966ae7b..745f148 100644 --- a/bitcoin_usb/usb_gui.py +++ b/bitcoin_usb/usb_gui.py @@ -1,7 +1,9 @@ +import asyncio import logging import platform import re import tempfile +from collections.abc import Callable from functools import partial from pathlib import Path from typing import Any, cast @@ -12,13 +14,14 @@ from bitcoin_safe_lib.gui.qt.signal_tracker import SignalProtocol from bitcoin_safe_lib.gui.qt.util import question_dialog from bitcoin_safe_lib.util_os import xdg_open_file +from bleak import BleakClient, BleakScanner from hwilib.devices.bitbox02 import Bitbox02Client from PyQt6.QtCore import QObject, pyqtSignal from PyQt6.QtWidgets import QMessageBox, QPushButton from bitcoin_usb.address_types import AddressType -from bitcoin_usb.dialogs import DeviceDialog, ThreadedWaitingDialog, get_message_box -from bitcoin_usb.hwi_quick import HWIQuick +from bitcoin_usb.dialogs import DeviceDialog, get_message_box +from bitcoin_usb.jade_ble_client import discover_jade_ble_devices from bitcoin_usb.util import run_device_task from .device import USBDevice, bdknetwork_to_chain @@ -27,6 +30,21 @@ logger = logging.getLogger(__name__) +def is_ble_available() -> bool: + return BleakClient is not None and BleakScanner is not None + + +def can_scan_bluetooth_devices(probe_timeout: float = 0.2) -> bool: + if not is_ble_available(): + return False + try: + asyncio.run(BleakScanner.discover(timeout=max(0.1, probe_timeout))) + except Exception as e: + logger.info("Bluetooth scanning unavailable in this environment: %s", e) + return False + return True + + def clean_string(input_string: str) -> str: """ Removes special characters from a string and replaces spaces with underscores. @@ -58,12 +76,15 @@ def __init__( autoselect_if_1_device=False, initalization_label="", parent=None, + enable_bluetooth: bool = True, ) -> None: super().__init__() self.autoselect_if_1_device = autoselect_if_1_device self.network = network self.loop_in_thread = loop_in_thread self._parent = parent + self.enable_bluetooth = enable_bluetooth + self._bluetooth_scan_supported: bool | None = None self.initalization_label = clean_string(initalization_label) self.allow_emulators_only_for_testnet_works = allow_emulators_only_for_testnet_works @@ -71,74 +92,55 @@ def set_initalization_label(self, value: str): self.initalization_label = clean_string(value) def get_devices(self, slow_hwi_listing=False) -> list[dict[str, Any]]: - "Returns the found devices WITHOUT unlocking them first. Misses the fingerprints" - allow_emulators = False - devices: list[dict[str, Any]] = [] - - try: - if slow_hwi_listing: - allow_emulators = True - if self.allow_emulators_only_for_testnet_works: - allow_emulators = self.network in [ - bdk.Network.REGTEST, - bdk.Network.TESTNET, - bdk.Network.SIGNET, - ] - - devices = ThreadedWaitingDialog( - partial( - hwi_commands.enumerate, - allow_emulators=allow_emulators, - chain=bdknetwork_to_chain(self.network), - ), - title=self.tr("Unlock USB devices"), - message=self.tr("Please unlock USB devices"), - ).get_result() - else: - devices = HWIQuick(network=self.network).enumerate() - - except Exception as e: - logger.error(str(e)) - return devices + "Enumerate available HWI devices." + allow_emulators = bool(slow_hwi_listing) + if allow_emulators: + allow_emulators = True + if self.allow_emulators_only_for_testnet_works: + allow_emulators = self.network in [ + bdk.Network.REGTEST, + bdk.Network.TESTNET, + bdk.Network.SIGNET, + ] + return hwi_commands.enumerate( + allow_emulators=allow_emulators, chain=bdknetwork_to_chain(self.network) + ) def get_device(self, slow_hwi_listing=False) -> dict[str, Any] | None: "Returns the found devices WITHOUT unlocking them first. Misses the fingerprints" - devices = self.get_devices(slow_hwi_listing=slow_hwi_listing) - - if not devices: - if platform.system() == "Linux": - if ( - question_dialog( - text=self.tr("No USB devices found. It could be due to missing udev rules."), - title=self.tr("USB Devices"), - false_button="Install udev rules", - true_button=QMessageBox.StandardButton.Ok, - ) - is False - ): - self.linux_cmd_install_udev_as_sudo() - else: - get_message_box( - translate("bitcoin_usb", "No USB devices found"), - title=translate("bitcoin_usb", "USB Devices"), - ).exec() - - self.signal_end_hwi_blocker.emit() - return None - if len(devices) == 1 and self.autoselect_if_1_device: - return devices[0] - else: - dialog = DeviceDialog(self._parent, devices, self.network) - if dialog.exec(): - return dialog.get_selected_device() - else: - get_message_box( - self.tr("No device selected"), - title=self.tr("USB Devices"), - ).exec() - self.signal_end_hwi_blocker.emit() + bluetooth_scan_callback: Callable[[], list[dict[str, Any]]] | None = None + if self._is_bluetooth_scan_supported(): + bluetooth_scan_callback = self.get_bluetooth_devices + + dialog = DeviceDialog( + self._parent, + network=self.network, + usb_scan_callback=partial(self.get_devices, slow_hwi_listing=slow_hwi_listing), + bluetooth_scan_callback=bluetooth_scan_callback, + install_udev_callback=self.linux_cmd_install_udev_as_sudo + if platform.system() == "Linux" + else None, + autoselect_if_1_device=self.autoselect_if_1_device, + ) + if dialog.exec(): + return dialog.get_selected_device() + self.signal_end_hwi_blocker.emit() return None + def get_bluetooth_devices(self) -> list[dict[str, Any]]: + if not self.enable_bluetooth: + raise RuntimeError(self.tr("Bluetooth support is disabled by configuration.")) + if not self._is_bluetooth_scan_supported(): + raise RuntimeError(self.tr("Bluetooth scanning is not available in this environment.")) + return discover_jade_ble_devices(scan_timeout=6.0) + + def _is_bluetooth_scan_supported(self) -> bool: + if not self.enable_bluetooth: + return False + if self._bluetooth_scan_supported is None: + self._bluetooth_scan_supported = can_scan_bluetooth_devices() + return self._bluetooth_scan_supported + def sign(self, psbt: bdk.Psbt, slow_hwi_listing=False) -> bdk.Psbt | None: selected_device = self.get_device(slow_hwi_listing=slow_hwi_listing) if not selected_device: diff --git a/bitcoin_usb/util.py b/bitcoin_usb/util.py index 261d5e2..e46c48f 100644 --- a/bitcoin_usb/util.py +++ b/bitcoin_usb/util.py @@ -1,5 +1,6 @@ import asyncio import logging +import os import subprocess import sys from collections.abc import Callable @@ -72,3 +73,15 @@ def _on_error(exc_info): raise caught[0] return result[0] if result else None + + +def resource_path(*parts: str): + # absolute path to python package folder ("lib") + """Resource path.""" + pkg_dir = os.path.split(os.path.realpath(__file__))[0] + return os.path.join(pkg_dir, *parts) + + +def get_icon_path(icon_basename: str) -> str: + """Get icon path.""" + return resource_path("icons", icon_basename) diff --git a/poetry.lock b/poetry.lock index b4e4dc3..2ef2b63 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,29 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. + +[[package]] +name = "aioitertools" +version = "0.13.0" +description = "itertools and builtins for AsyncIO and mixed iterables" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aioitertools-0.13.0-py3-none-any.whl", hash = "sha256:0be0292b856f08dfac90e31f4739432f4cb6d7520ab9eb73e143f4f2fa5259be"}, + {file = "aioitertools-0.13.0.tar.gz", hash = "sha256:620bd241acc0bbb9ec819f1ab215866871b4bbd1f73836a55f799200ee86950c"}, +] + +[[package]] +name = "async-timeout" +version = "4.0.3" +description = "Timeout context manager for asyncio programs" +optional = false +python-versions = ">=3.7" +groups = ["main"] +markers = "python_version == \"3.10\"" +files = [ + {file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"}, + {file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"}, +] [[package]] name = "base58" @@ -62,6 +87,57 @@ url = "https://github.com/andreasgriffin/bitcoin-safe-lib.git" reference = "main" resolved_reference = "6a2629cc32335188f8a73f759ea936c1cea6b6cc" +[[package]] +name = "bleak" +version = "0.22.3" +description = "Bluetooth Low Energy platform Agnostic Klient" +optional = false +python-versions = "<3.14,>=3.8" +groups = ["main"] +files = [ + {file = "bleak-0.22.3-py3-none-any.whl", hash = "sha256:1e62a9f5e0c184826e6c906e341d8aca53793e4596eeaf4e0b191e7aca5c461c"}, + {file = "bleak-0.22.3.tar.gz", hash = "sha256:3149c3c19657e457727aa53d9d6aeb89658495822cd240afd8aeca4dd09c045c"}, +] + +[package.dependencies] +async-timeout = {version = ">=3.0.0,<5", markers = "python_version < \"3.11\""} +bleak-winrt = {version = ">=1.2.0,<2.0.0", markers = "platform_system == \"Windows\" and python_version < \"3.12\""} +dbus-fast = {version = ">=1.83.0,<3", markers = "platform_system == \"Linux\""} +pyobjc-core = {version = ">=10.3,<11.0", markers = "platform_system == \"Darwin\""} +pyobjc-framework-CoreBluetooth = {version = ">=10.3,<11.0", markers = "platform_system == \"Darwin\""} +pyobjc-framework-libdispatch = {version = ">=10.3,<11.0", markers = "platform_system == \"Darwin\""} +typing-extensions = {version = ">=4.7.0", markers = "python_version < \"3.12\""} +winrt-runtime = {version = ">=2,<3", markers = "platform_system == \"Windows\" and python_version >= \"3.12\""} +"winrt-Windows.Devices.Bluetooth" = {version = ">=2,<3", markers = "platform_system == \"Windows\" and python_version >= \"3.12\""} +"winrt-Windows.Devices.Bluetooth.Advertisement" = {version = ">=2,<3", markers = "platform_system == \"Windows\" and python_version >= \"3.12\""} +"winrt-Windows.Devices.Bluetooth.GenericAttributeProfile" = {version = ">=2,<3", markers = "platform_system == \"Windows\" and python_version >= \"3.12\""} +"winrt-Windows.Devices.Enumeration" = {version = ">=2,<3", markers = "platform_system == \"Windows\" and python_version >= \"3.12\""} +"winrt-Windows.Foundation" = {version = ">=2,<3", markers = "platform_system == \"Windows\" and python_version >= \"3.12\""} +"winrt-Windows.Foundation.Collections" = {version = ">=2,<3", markers = "platform_system == \"Windows\" and python_version >= \"3.12\""} +"winrt-Windows.Storage.Streams" = {version = ">=2,<3", markers = "platform_system == \"Windows\" and python_version >= \"3.12\""} + +[[package]] +name = "bleak-winrt" +version = "1.2.0" +description = "Python WinRT bindings for Bleak" +optional = false +python-versions = "*" +groups = ["main"] +markers = "platform_system == \"Windows\" and python_version < \"3.12\"" +files = [ + {file = "bleak-winrt-1.2.0.tar.gz", hash = "sha256:0577d070251b9354fc6c45ffac57e39341ebb08ead014b1bdbd43e211d2ce1d6"}, + {file = "bleak_winrt-1.2.0-cp310-cp310-win32.whl", hash = "sha256:a2ae3054d6843ae0cfd3b94c83293a1dfd5804393977dd69bde91cb5099fc47c"}, + {file = "bleak_winrt-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:677df51dc825c6657b3ae94f00bd09b8ab88422b40d6a7bdbf7972a63bc44e9a"}, + {file = "bleak_winrt-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9449cdb942f22c9892bc1ada99e2ccce9bea8a8af1493e81fefb6de2cb3a7b80"}, + {file = "bleak_winrt-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:98c1b5a6a6c431ac7f76aa4285b752fe14a1c626bd8a1dfa56f66173ff120bee"}, + {file = "bleak_winrt-1.2.0-cp37-cp37m-win32.whl", hash = "sha256:623ac511696e1f58d83cb9c431e32f613395f2199b3db7f125a3d872cab968a4"}, + {file = "bleak_winrt-1.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:13ab06dec55469cf51a2c187be7b630a7a2922e1ea9ac1998135974a7239b1e3"}, + {file = "bleak_winrt-1.2.0-cp38-cp38-win32.whl", hash = "sha256:5a36ff8cd53068c01a795a75d2c13054ddc5f99ce6de62c1a97cd343fc4d0727"}, + {file = "bleak_winrt-1.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:810c00726653a962256b7acd8edf81ab9e4a3c66e936a342ce4aec7dbd3a7263"}, + {file = "bleak_winrt-1.2.0-cp39-cp39-win32.whl", hash = "sha256:dd740047a08925bde54bec357391fcee595d7b8ca0c74c87170a5cbc3f97aa0a"}, + {file = "bleak_winrt-1.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:63130c11acfe75c504a79c01f9919e87f009f5e742bfc7b7a5c2a9c72bf591a7"}, +] + [[package]] name = "cbor2" version = "5.7.1" @@ -491,6 +567,54 @@ ssh = ["bcrypt (>=3.1.5)"] test = ["certifi (>=2024)", "cryptography-vectors (==46.0.3)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] test-randomorder = ["pytest-randomly"] +[[package]] +name = "dbus-fast" +version = "2.46.4" +description = "A faster version of dbus-next" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "platform_system == \"Linux\"" +files = [ + {file = "dbus_fast-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1c221fc0160cd154a92760e0df427389cc217f5e03aa3457796d9d4845f3be8f"}, + {file = "dbus_fast-2.46.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a597fbc4be746507d736f2901cea37604c252a7dfc812b1449a159f3bd203c3a"}, + {file = "dbus_fast-2.46.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:68236d5cb123db3116a83c73bb085a9afeba5a3fe0e77891473642ceda90c2a5"}, + {file = "dbus_fast-2.46.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:815340223be53f7e4faf85beea789b45bf53093fef99dc71532eba5e21046823"}, + {file = "dbus_fast-2.46.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4c585f81096433cf89cc6391fa42c0fe3a297bb7ed45a2ef37ba5f135eea0f01"}, + {file = "dbus_fast-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5e18381e53672954c3332638f066729a1759d234d24c71f4a8627676c6894d3a"}, + {file = "dbus_fast-2.46.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b3abb2c8d63d6c09839dc504c0f62b179534d0f7d188081ad6e2740645a9759"}, + {file = "dbus_fast-2.46.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e4ed44270795b6e15cbe7fa2022705ed0c7d8053d92eb7d556300e1b734536d8"}, + {file = "dbus_fast-2.46.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3106e78bfd9c675ac4ca2e73b638018375f87460efee10480e3d82ec5a5cfdc7"}, + {file = "dbus_fast-2.46.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c6f638bbcde35fb207c07b347d22e73fe729f6fcf834958d8c9ad8586813dbda"}, + {file = "dbus_fast-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:303c2dae933bfd7c8a77e19755483755a71ec423d01baa88067ce2b27dcc5d30"}, + {file = "dbus_fast-2.46.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863c5b72341d96f228c127057cbaadd3ad237ee05f9f1843c12fdf06b9bd1da1"}, + {file = "dbus_fast-2.46.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c271b0cdfe03bf8cd815ad3f35ca47a8772e749ce0081fa5bdb9c22e121f1c10"}, + {file = "dbus_fast-2.46.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c87a41e1d1b00ae2aee27c82152d90ae57c6b112b22d404743bf8acec8ce3038"}, + {file = "dbus_fast-2.46.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34b435ff62763a581fe9f0acc045b6e966e4ad2cc708ef6a7b90cdc8f366c55c"}, + {file = "dbus_fast-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5b86cfe64a46100c18d47ad96cdc021de428825d85f0294fea3aeba299f39e73"}, + {file = "dbus_fast-2.46.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0da0d799b88330b419cca2ea32398264a11cf575f26ee8cca9cd87298e8cb7cd"}, + {file = "dbus_fast-2.46.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cc59b9d6ee59ab58d8cdc58f19a4e5ad6451392d215933eedd4482925e8ecf1"}, + {file = "dbus_fast-2.46.4-cp313-cp313-manylinux_2_36_x86_64.whl", hash = "sha256:c06e257db95f032eb27a1c6441036726cb5d0b966343a19d1e7a060d5aefbaad"}, + {file = "dbus_fast-2.46.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3ac31600cdf6bb8daac02299ae70e7f9db241e1f2e19d906d25141b3bdb64221"}, + {file = "dbus_fast-2.46.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:153ee85ee8fc7a80bdcefe0f6d205148a701750d9faa9191379ab655e9f6023e"}, + {file = "dbus_fast-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:eee31f7e6987538f9d344001ea3ab846c7446137a96a52b7caa537953044c49d"}, + {file = "dbus_fast-2.46.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8eb9dfa6dc15a814c3f134e407211de3e2b75baa7d129ca3e1be64acd3fa0121"}, + {file = "dbus_fast-2.46.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:018c7d878857599e3478c56575aa2ed9ff52eb7a88c5118685db8e033b3916e9"}, + {file = "dbus_fast-2.46.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d819ad4193d538308e775ed2a9ed1f56121c8135fe0d4101a72c9eb639aa0f5f"}, + {file = "dbus_fast-2.46.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5b70d88930be35a07a7fe838e41d608eb6a84c45506e2b9cf467ae70f2083f75"}, + {file = "dbus_fast-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:704c529511fd8c89716ccba17063376e198410c56860f29e919e62765fc45c5c"}, + {file = "dbus_fast-2.46.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4ad88a3337add1230af5c15fd1dbde1ee23ff0207f8ecca13b3c0a60f2ef109"}, + {file = "dbus_fast-2.46.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb209ac6aa69715383b84e720119173f5b4450cf47214a38aea4007c9461c1e9"}, + {file = "dbus_fast-2.46.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:52094d593d40c4ddc1e23db839caa088bf9c16f1960325a524244263e1bda2d5"}, + {file = "dbus_fast-2.46.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7c247915211c1aa0698dd2ecbef8e4b4d8429dffe29333bf9c6e1899783bfc7"}, + {file = "dbus_fast-2.46.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dcbf8651ce2eb18035aaa2f06264cd1f8f8294222b541d03122bc59c47cc944f"}, + {file = "dbus_fast-2.46.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3797c629098891adc4f9251b9949d658db5676ba5e2016f981594a9efb54ad95"}, + {file = "dbus_fast-2.46.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f97cedb248644ee63334ddbbb0bec25b60e2f88117c7fcbdc2f103fb8c536084"}, + {file = "dbus_fast-2.46.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:74690192456e39c9bb2061aecb2751120f826ec8b85db32618e50b099ed6380e"}, + {file = "dbus_fast-2.46.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9d788ffec57c348abb7cbf2dd73760c831cbdcddf096cdfb7a251ce20a6cb506"}, + {file = "dbus_fast-2.46.4.tar.gz", hash = "sha256:7c1f0c6fbb04d51c87fd05f54e4538176a66bb35a8cfdca642904491dfd35e00"}, +] + [[package]] name = "ecdsa" version = "0.19.1" @@ -679,6 +803,30 @@ files = [ {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, ] +[[package]] +name = "jade_client" +version = "1.0.39" +description = "Blockstream Jade Client API" +optional = false +python-versions = "*" +groups = ["main"] +files = [] +develop = false + +[package.dependencies] +cbor2 = ">=5.4.6,<6.0.0" +pyserial = ">=3.5.0,<4.0.0" + +[package.extras] +ble = ["aioitertools (==0.8.0)", "bleak (==0.13.0)"] +requests = ["requests (>=2.26.0,<3.0.0)"] + +[package.source] +type = "git" +url = "https://github.com/Blockstream/Jade.git" +reference = "fb2642397ed7afdda08f3d1380ac6ee21164e7e6" +resolved_reference = "fb2642397ed7afdda08f3d1380ac6ee21164e7e6" + [[package]] name = "libusb1" version = "3.3.1" @@ -873,6 +1021,94 @@ files = [ [package.extras] windows-terminal = ["colorama (>=0.4.6)"] +[[package]] +name = "pyobjc-core" +version = "10.3.2" +description = "Python<->ObjC Interoperability Module" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "platform_system == \"Darwin\"" +files = [ + {file = "pyobjc_core-10.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:acb40672d682851a5c7fd84e5041c4d069b62076168d72591abb5fcc871bb039"}, + {file = "pyobjc_core-10.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:cea5e77659619ad93c782ca07644b6efe7d7ec6f59e46128843a0a87c1af511a"}, + {file = "pyobjc_core-10.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:16644a92fb9661de841ba6115e5354db06a1d193a5e239046e840013c7b3874d"}, + {file = "pyobjc_core-10.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:76b8b911d94501dac89821df349b1860bb770dce102a1a293f524b5b09dd9462"}, + {file = "pyobjc_core-10.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:8c6288fdb210b64115760a4504efbc4daffdc390d309e9318eb0e3e3b78d2828"}, + {file = "pyobjc_core-10.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:87901e9f7032f33eb4fa884e407bf2744d5a0791b379bfca783982a02be3f7fb"}, + {file = "pyobjc_core-10.3.2-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:636971ab48a4198ca129e149fe58ccf85a7b4a9b93d27f5ae920d88eb2655431"}, + {file = "pyobjc_core-10.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:48e9ac3af42b2340dae709a8b894f5ef7e5132d8546adcd1797cffcc449dabdc"}, + {file = "pyobjc_core-10.3.2.tar.gz", hash = "sha256:dbf1475d864ce594288ce03e94e3a98dc7f0e4639971eb1e312bdf6661c21e0e"}, +] + +[[package]] +name = "pyobjc-framework-cocoa" +version = "10.3.2" +description = "Wrappers for the Cocoa frameworks on macOS" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "platform_system == \"Darwin\"" +files = [ + {file = "pyobjc_framework_Cocoa-10.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:61f44c2adab28fdf3aa3d593c9497a2d9ceb9583ed9814adb48828c385d83ff4"}, + {file = "pyobjc_framework_Cocoa-10.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7caaf8b260e81b27b7b787332846f644b9423bfc1536f6ec24edbde59ab77a87"}, + {file = "pyobjc_framework_Cocoa-10.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c49e99fc4b9e613fb308651b99d52a8a9ae9916c8ef27aa2f5d585b6678a59bf"}, + {file = "pyobjc_framework_Cocoa-10.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f1161b5713f9b9934c12649d73a6749617172e240f9431eff9e22175262fdfda"}, + {file = "pyobjc_framework_Cocoa-10.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:08e48b9ee4eb393447b2b781d16663b954bd10a26927df74f92e924c05568d89"}, + {file = "pyobjc_framework_Cocoa-10.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7faa448d2038ae0e0287a326d390002e744bb6470e45995e2dbd16c892e4495a"}, + {file = "pyobjc_framework_Cocoa-10.3.2-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:fcd53fee2be9708576617994b107aedc2c40824b648cd51e780e8399c0a447b6"}, + {file = "pyobjc_framework_Cocoa-10.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:838fcf0d10674bde9ff64a3f20c0e188f2dc5e804476d80509b81c4ac1dabc59"}, + {file = "pyobjc_framework_cocoa-10.3.2.tar.gz", hash = "sha256:673968e5435845bef969bfe374f31a1a6dc660c98608d2b84d5cae6eafa5c39d"}, +] + +[package.dependencies] +pyobjc-core = ">=10.3.2" + +[[package]] +name = "pyobjc-framework-corebluetooth" +version = "10.3.2" +description = "Wrappers for the framework CoreBluetooth on macOS" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "platform_system == \"Darwin\"" +files = [ + {file = "pyobjc_framework_CoreBluetooth-10.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:af3e2f935a6a7e5b009b4cf63c64899592a7b46c3ddcbc8f2e28848842ef65f4"}, + {file = "pyobjc_framework_CoreBluetooth-10.3.2-cp36-abi3-macosx_10_13_universal2.whl", hash = "sha256:973b78f47c7e2209a475e60bcc7d1b4a87be6645d39b4e8290ee82640e1cc364"}, + {file = "pyobjc_framework_CoreBluetooth-10.3.2-cp36-abi3-macosx_10_9_universal2.whl", hash = "sha256:4bafdf1be15eae48a4878dbbf1bf19877ce28cbbba5baa0267a9564719ee736e"}, + {file = "pyobjc_framework_CoreBluetooth-10.3.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:4d7dc7494de66c850bda7b173579df7481dc97046fa229d480fe9bf90b2b9651"}, + {file = "pyobjc_framework_CoreBluetooth-10.3.2-cp36-abi3-macosx_11_0_universal2.whl", hash = "sha256:62e09e730f4d98384f1b6d44718812195602b3c82d5c78e09f60e8a934e7b266"}, + {file = "pyobjc_framework_corebluetooth-10.3.2.tar.gz", hash = "sha256:c0a077bc3a2466271efa382c1e024630bc43cc6f9ab8f3f97431ad08b1ad52bb"}, +] + +[package.dependencies] +pyobjc-core = ">=10.3.2" +pyobjc-framework-Cocoa = ">=10.3.2" + +[[package]] +name = "pyobjc-framework-libdispatch" +version = "10.3.2" +description = "Wrappers for libdispatch on macOS" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "platform_system == \"Darwin\"" +files = [ + {file = "pyobjc_framework_libdispatch-10.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:35233a8b1135567c7696087f924e398799467c7f129200b559e8e4fa777af860"}, + {file = "pyobjc_framework_libdispatch-10.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:061f6aa0f88d11d993e6546ec734303cb8979f40ae0f5cd23541236a6b426abd"}, + {file = "pyobjc_framework_libdispatch-10.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:6bb528f34538f35e1b79d839dbfc398dd426990e190d9301fe2d811fddc3da62"}, + {file = "pyobjc_framework_libdispatch-10.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1357729d5fded08fbf746834ebeef27bee07d6acb991f3b8366e8f4319d882c4"}, + {file = "pyobjc_framework_libdispatch-10.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:210398f9e1815ceeff49b578bf51c2d6a4a30d4c33f573da322f3d7da1add121"}, + {file = "pyobjc_framework_libdispatch-10.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e7ae5988ac0b369ad40ce5497af71864fac45c289fa52671009b427f03d6871f"}, + {file = "pyobjc_framework_libdispatch-10.3.2-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:f9d51d52dff453a4b19c096171a6cd31dd5e665371c00c1d72d480e1c22cd3d4"}, + {file = "pyobjc_framework_libdispatch-10.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ef755bcabff2ea8db45603a8294818e0eeae85bf0b7b9d59e42f5947a26e33b9"}, + {file = "pyobjc_framework_libdispatch-10.3.2.tar.gz", hash = "sha256:e9f4311fbf8df602852557a98d2a64f37a9d363acf4d75634120251bbc7b7304"}, +] + +[package.dependencies] +pyobjc-core = ">=10.3.2" +pyobjc-framework-Cocoa = ">=10.3.2" + [[package]] name = "pyqt6" version = "6.10.2" @@ -1183,7 +1419,265 @@ h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] +[[package]] +name = "winrt-runtime" +version = "2.3.0" +description = "Python projection of Windows Runtime (WinRT) APIs" +optional = false +python-versions = "<3.14,>=3.9" +groups = ["main"] +markers = "platform_system == \"Windows\" and python_version == \"3.12\"" +files = [ + {file = "winrt_runtime-2.3.0-cp310-cp310-win32.whl", hash = "sha256:5c22ed339b420a6026134e28281b25078a9e6755eceb494dce5d42ee5814e3fd"}, + {file = "winrt_runtime-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:f3ef0d6b281a8d4155ea14a0f917faf82a004d4996d07beb2b3d2af191503fb1"}, + {file = "winrt_runtime-2.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:93ce23df52396ed89dfe659ee0e1a968928e526b9c577942d4a54ad55b333644"}, + {file = "winrt_runtime-2.3.0-cp311-cp311-win32.whl", hash = "sha256:352d70864846fd7ec89703845b82a35cef73f42d178a02a4635a38df5a61c0f8"}, + {file = "winrt_runtime-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:286e6036af4903dd830398103c3edd110a46432347e8a52ba416d937c0e1f5f9"}, + {file = "winrt_runtime-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:44d0f0f48f2f10c02b885989e8bbac41d7bf9c03550b20ddf562100356fca7a9"}, + {file = "winrt_runtime-2.3.0-cp312-cp312-win32.whl", hash = "sha256:03d3e4aedc65832e57c0dbf210ec2a9d7fb2819c74d420ba889b323e9fa5cf28"}, + {file = "winrt_runtime-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:0dc636aec2f4ee6c3849fa59dae10c128f4a908f0ce452e91af65d812ea66dcb"}, + {file = "winrt_runtime-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:d9f140c71e4f3bf7bf7d6853b246eab2e1632c72f218ff163aa41a74b576736f"}, + {file = "winrt_runtime-2.3.0-cp313-cp313-win32.whl", hash = "sha256:77f06df6b7a6cb536913ae455e30c1733d31d88dafe2c3cd8c3d0e2bcf7e2a20"}, + {file = "winrt_runtime-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:7388774b74ea2f4510ab3a98c95af296665ebe69d9d7e2fd7ee2c3fc5856099e"}, + {file = "winrt_runtime-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:0d3a4ac7661cad492d51653054e63328b940a6083c1ee1dd977f90069cb8afaa"}, + {file = "winrt_runtime-2.3.0-cp39-cp39-win32.whl", hash = "sha256:cd7bce2c7703054e7f64d11be665e9728e15d9dae0d952a51228fe830e0c4b55"}, + {file = "winrt_runtime-2.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:2da01af378ab9374a3a933da97543f471a676a3b844318316869bffeff811e8a"}, + {file = "winrt_runtime-2.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:1c6bbfcc7cbe1c8159ed5d776b30b7f1cbc2c6990803292823b0788c22d75636"}, + {file = "winrt_runtime-2.3.0.tar.gz", hash = "sha256:bb895a2b8c74b375781302215e2661914369c625aa1f8df84f8d37691b22db77"}, +] + +[[package]] +name = "winrt-windows-devices-bluetooth" +version = "2.3.0" +description = "Python projection of Windows Runtime (WinRT) APIs" +optional = false +python-versions = "<3.14,>=3.9" +groups = ["main"] +markers = "platform_system == \"Windows\" and python_version == \"3.12\"" +files = [ + {file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp310-cp310-win32.whl", hash = "sha256:554aa6d0ca4bebc22a45f19fa60db1183a2b5643468f3c95cf0ebc33fbc1b0d0"}, + {file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:cec2682e10431f027c1823647772671fb09bebc1e8a00021a3651120b846d36f"}, + {file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:b4d42faef99845de2aded4c75c906f03cc3ba3df51fb4435e4cc88a19168cf99"}, + {file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp311-cp311-win32.whl", hash = "sha256:64e0992175d4d5a1160179a8c586c2202a0edbd47a5b6da4efdbc8bb601f2f99"}, + {file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:0830111c077508b599062fbe2d817203e4efa3605bd209cf4a3e03388ec39dda"}, + {file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:3943d538cb7b6bde3fd8741591eb6e23487ee9ee6284f05428b205e7d10b6d92"}, + {file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp312-cp312-win32.whl", hash = "sha256:544ed169039e6d5e250323cc18c87967cfeb4d3d09ce354fd7c5fd2283f3bb98"}, + {file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:f7becf095bf9bc999629fcb6401a88b879c3531b3c55c820e63259c955ddc06c"}, + {file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:a6a2980409c855b4e5dab0be9bde9f30236292ac1fc994df959fa5a518cd6690"}, + {file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp313-cp313-win32.whl", hash = "sha256:82f443be43379d4762e72633047c82843c873b6f26428a18855ca7b53e1958d7"}, + {file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:8b407da87ab52315c2d562a75d824dcafcae6e1628031cdb971072a47eb78ff0"}, + {file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:e36d0b487bc5b64662b8470085edf8bfa5a220d7afc4f2e8d7faa3e3ac2bae80"}, + {file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp39-cp39-win32.whl", hash = "sha256:6553023433edf5a75767e8962bf492d0623036975c7d8373d5bbccc633a77bbc"}, + {file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:77bdeadb043190c40ebbad462cd06e38b6461bc976bc67daf587e9395c387aae"}, + {file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:c588ab79b534fedecce48f7082b419315e8d797d0120556166492e603e90d932"}, + {file = "winrt_windows_devices_bluetooth-2.3.0.tar.gz", hash = "sha256:a1204b71c369a0399ec15d9a7b7c67990dd74504e486b839bf81825bd381a837"}, +] + +[package.dependencies] +winrt-runtime = "2.3.0" + +[package.extras] +all = ["winrt-Windows.Devices.Bluetooth.GenericAttributeProfile[all] (==2.3.0)", "winrt-Windows.Devices.Bluetooth.Rfcomm[all] (==2.3.0)", "winrt-Windows.Devices.Enumeration[all] (==2.3.0)", "winrt-Windows.Devices.Radios[all] (==2.3.0)", "winrt-Windows.Foundation.Collections[all] (==2.3.0)", "winrt-Windows.Foundation[all] (==2.3.0)", "winrt-Windows.Networking[all] (==2.3.0)", "winrt-Windows.Storage.Streams[all] (==2.3.0)"] + +[[package]] +name = "winrt-windows-devices-bluetooth-advertisement" +version = "2.3.0" +description = "Python projection of Windows Runtime (WinRT) APIs" +optional = false +python-versions = "<3.14,>=3.9" +groups = ["main"] +markers = "platform_system == \"Windows\" and python_version == \"3.12\"" +files = [ + {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp310-cp310-win32.whl", hash = "sha256:4386498e7794ed383542ea868f0aa2dd8fb5f09f12bdffde024d12bd9f5a3756"}, + {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:d6fa25b2541d2898ae17982e86e0977a639b04f75119612cb46e1719474513fd"}, + {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:b200ff5acd181353f61f5b6446176faf78a61867d8c1d21e77a15e239d2cdf6b"}, + {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp311-cp311-win32.whl", hash = "sha256:e56ad277813b48e35a3074f286c55a7a25884676e23ef9c3fc12349a42cb8fa4"}, + {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:d6533fef6a5914dc8d519b83b1841becf6fd2f37163d6e07df318a6a6118f194"}, + {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:8f4369cb0108f8ee0cace559f9870b00a4dde3fc1abd52f84adba08bc733825c"}, + {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp312-cp312-win32.whl", hash = "sha256:d729d989acd7c1d703e2088299b6e219089a415db4a7b80cd52fdc507ec3ce95"}, + {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d3d258d4388a2b46f2e46f2fbdede1bf327eaa9c2dd4605f8a7fe454077c49e"}, + {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:d8c12457b00a79f8f1058d7a51bd8e7f177fb66e31389469e75b1104f6358921"}, + {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp313-cp313-win32.whl", hash = "sha256:ac1e55a350881f82cb51e162cb7a4b5d9359e9e5fbde922de802404a951d64ec"}, + {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0fc339340fb8be21c1c829816a49dc31b986c6d602d113d4a49ee8ffaf0e2396"}, + {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:da63d9c56edcb3b2d5135e65cc8c9c4658344dd480a8a2daf45beb2106f17874"}, + {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp39-cp39-win32.whl", hash = "sha256:e98c6ae4b0afd3e4f3ab4fa06e84d6017ff9242146a64e3bad73f7f34183a076"}, + {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:cdc485f4143fbbb3ae0c9c9ad03b1021a5cb233c6df65bf56ac14f8e22c918c3"}, + {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:7af519cc895be84d6974e9f70d102545a5e8db05e065903b0fd84521218e60a9"}, + {file = "winrt_windows_devices_bluetooth_advertisement-2.3.0.tar.gz", hash = "sha256:c8adbec690b765ca70337c35efec9910b0937a40a0a242184ea295367137f81c"}, +] + +[package.dependencies] +winrt-runtime = "2.3.0" + +[package.extras] +all = ["winrt-Windows.Devices.Bluetooth[all] (==2.3.0)", "winrt-Windows.Foundation.Collections[all] (==2.3.0)", "winrt-Windows.Foundation[all] (==2.3.0)", "winrt-Windows.Storage.Streams[all] (==2.3.0)"] + +[[package]] +name = "winrt-windows-devices-bluetooth-genericattributeprofile" +version = "2.3.0" +description = "Python projection of Windows Runtime (WinRT) APIs" +optional = false +python-versions = "<3.14,>=3.9" +groups = ["main"] +markers = "platform_system == \"Windows\" and python_version == \"3.12\"" +files = [ + {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp310-cp310-win32.whl", hash = "sha256:1ec75b107370827874d8435a47852d0459cb66d5694e02a833e0a75c4748e847"}, + {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:0a178aa936abbc56ae1cc54a222dee4a34ce6c09506a5b592d4f7d04dbe76b95"}, + {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:b7067b8578e19ad17b28694090d5b000fee57db5b219462155961b685d71fba5"}, + {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp311-cp311-win32.whl", hash = "sha256:e0aeba201e20b6c4bc18a4336b5b07d653d4ab4c9c17a301613db680a346cd5e"}, + {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:f87b3995de18b98075ec2b02afc7252873fa75e7c840eb770d7bfafb4fda5c12"}, + {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:7dccce04ec076666001efca8e2484d0ec444b2302ae150ef184aa253b8cfba09"}, + {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp312-cp312-win32.whl", hash = "sha256:1b97ef2ab9c9f5bae984989a47565d0d19c84969d74982a2664a4a3485cb8274"}, + {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:5fac2c7b301fa70e105785d7504176c76e4d824fc3823afed4d1ab6a7682272c"}, + {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:353fdccf2398b2a12e0835834cff8143a7efd9ba877fb5820fdcce531732b500"}, + {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp313-cp313-win32.whl", hash = "sha256:f414f793767ccc56d055b1c74830efb51fa4cbdc9163847b1a38b1ee35778f49"}, + {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ef35d9cda5bbdcc55aa7eaf143ab873227d6ee467aaf28edbd2428f229e7c94"}, + {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:6a9e7308ba264175c2a9ee31f6cf1d647cb35ee9a1da7350793d8fe033a6b9b8"}, + {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp39-cp39-win32.whl", hash = "sha256:aea58f7e484cf3480ab9472a3e99b61c157b8a47baae8694bc7400ea5335f5dc"}, + {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:992b792a9e7f5771ccdc18eec4e526a11f23b75d9be5de3ec552ff719333897a"}, + {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:66b030a9cc6099dafe4253239e8e625cc063bb9bb115bebed6260d92dd86f6b1"}, + {file = "winrt_windows_devices_bluetooth_genericattributeprofile-2.3.0.tar.gz", hash = "sha256:f40f94bf2f7243848dc10e39cfde76c9044727a05e7e5dfb8cb7f062f3fd3dda"}, +] + +[package.dependencies] +winrt-runtime = "2.3.0" + +[package.extras] +all = ["winrt-Windows.Devices.Bluetooth[all] (==2.3.0)", "winrt-Windows.Devices.Enumeration[all] (==2.3.0)", "winrt-Windows.Foundation.Collections[all] (==2.3.0)", "winrt-Windows.Foundation[all] (==2.3.0)", "winrt-Windows.Storage.Streams[all] (==2.3.0)"] + +[[package]] +name = "winrt-windows-devices-enumeration" +version = "2.3.0" +description = "Python projection of Windows Runtime (WinRT) APIs" +optional = false +python-versions = "<3.14,>=3.9" +groups = ["main"] +markers = "platform_system == \"Windows\" and python_version == \"3.12\"" +files = [ + {file = "winrt_Windows.Devices.Enumeration-2.3.0-cp310-cp310-win32.whl", hash = "sha256:461360ab47967f39721e71276fdcfe87ad2f71ba7b09d721f2f88bcdf16a6924"}, + {file = "winrt_Windows.Devices.Enumeration-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:a7d7b01d43d5dcc1f3846db12f4c552155efae75469f36052623faed7f0f74a8"}, + {file = "winrt_Windows.Devices.Enumeration-2.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:6478fbe6f45172a9911c15b061ec9b0f30c9f4845ba3fd1e9e1bb78c1fb691c4"}, + {file = "winrt_Windows.Devices.Enumeration-2.3.0-cp311-cp311-win32.whl", hash = "sha256:30be5cba8e9e81ea8dd514ba1300b5bb14ad7cc4e32efe908ddddd14c73e7f61"}, + {file = "winrt_Windows.Devices.Enumeration-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:86c2a1865e0a0146dd4f51f17e3d773d3e6732742f61838c05061f28738c6dbd"}, + {file = "winrt_Windows.Devices.Enumeration-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:1b50d9304e49a9f04bc8139831b75be968ff19a1f50529d5eb0081dae2103d92"}, + {file = "winrt_Windows.Devices.Enumeration-2.3.0-cp312-cp312-win32.whl", hash = "sha256:42ed0349f0290a1b0a101425a06196c5d5db1240db6f8bd7d2204f23c48d727b"}, + {file = "winrt_Windows.Devices.Enumeration-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:83e385fbf85b9511699d33c659673611f42b98bd3a554a85b377a34cc3b68b2e"}, + {file = "winrt_Windows.Devices.Enumeration-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:26f855caee61c12449c6b07e22ea1ad470f8daa24223d8581e1fe622c70b48a8"}, + {file = "winrt_Windows.Devices.Enumeration-2.3.0-cp313-cp313-win32.whl", hash = "sha256:a5f2cff6ee584e5627a2246bdbcd1b3a3fd1e7ae0741f62c59f7d5a5650d5791"}, + {file = "winrt_Windows.Devices.Enumeration-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:7516171521aa383ccdc8f422cc202979a2359d0d1256f22852bfb0b55d9154f0"}, + {file = "winrt_Windows.Devices.Enumeration-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:80d01dfffe4b548439242f3f7a737189354768b203cca023dc29b267dfe5595a"}, + {file = "winrt_Windows.Devices.Enumeration-2.3.0-cp39-cp39-win32.whl", hash = "sha256:990a375cd8edc2d30b939a49dcc1349ede3a4b8e4da78baf0de5e5711d3a4f00"}, + {file = "winrt_Windows.Devices.Enumeration-2.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:2e7bedf0eac2066d7d37b1d34071b95bb57024e9e083867be1d24e916e012ac0"}, + {file = "winrt_Windows.Devices.Enumeration-2.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:c53b673b80ba794f1c1320a5e0a14d795193c3f64b8132ebafba2f49c7301c2f"}, + {file = "winrt_windows_devices_enumeration-2.3.0.tar.gz", hash = "sha256:a14078aac41432781acb0c950fcdcdeb096e2f80f7591a3d46435f30221fc3eb"}, +] + +[package.dependencies] +winrt-runtime = "2.3.0" + +[package.extras] +all = ["winrt-Windows.ApplicationModel.Background[all] (==2.3.0)", "winrt-Windows.Foundation.Collections[all] (==2.3.0)", "winrt-Windows.Foundation[all] (==2.3.0)", "winrt-Windows.Security.Credentials[all] (==2.3.0)", "winrt-Windows.Storage.Streams[all] (==2.3.0)", "winrt-Windows.UI.Popups[all] (==2.3.0)", "winrt-Windows.UI[all] (==2.3.0)"] + +[[package]] +name = "winrt-windows-foundation" +version = "2.3.0" +description = "Python projection of Windows Runtime (WinRT) APIs" +optional = false +python-versions = "<3.14,>=3.9" +groups = ["main"] +markers = "platform_system == \"Windows\" and python_version == \"3.12\"" +files = [ + {file = "winrt_Windows.Foundation-2.3.0-cp310-cp310-win32.whl", hash = "sha256:ea7b0e82be5c05690fedaf0dac5aa5e5fefd7ebf90b1497e5993197d305d916d"}, + {file = "winrt_Windows.Foundation-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:6807dd40f8ecd6403679f6eae0db81674fdcf33768d08fdee66e0a17b7a02515"}, + {file = "winrt_Windows.Foundation-2.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:0a861815e97ace82583210c03cf800507b0c3a97edd914bfffa5f88de1fbafcc"}, + {file = "winrt_Windows.Foundation-2.3.0-cp311-cp311-win32.whl", hash = "sha256:c79b3d9384128b6b28c2483b4600f15c5d32c1f6646f9d77fdb3ee9bbaef6f81"}, + {file = "winrt_Windows.Foundation-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:fdd9c4914070dc598f5961d9c7571dd7d745f5cc60347603bf39d6ee921bd85c"}, + {file = "winrt_Windows.Foundation-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:62bbb0ffa273551d33fd533d6e09b6f9f633dc214225d483722af47d2525fb84"}, + {file = "winrt_Windows.Foundation-2.3.0-cp312-cp312-win32.whl", hash = "sha256:d36f472ac258e79eee6061e1bb4ce50bfd200f9271392d23479c800ca6aee8d1"}, + {file = "winrt_Windows.Foundation-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:8de9b5e95a3fdabdb45b1952e05355dd5a678f80bf09a54d9f966dccc805b383"}, + {file = "winrt_Windows.Foundation-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:37da09c08c9c772baedb1958e5ee116fe63809f33c6820c69750f340b3dda292"}, + {file = "winrt_Windows.Foundation-2.3.0-cp313-cp313-win32.whl", hash = "sha256:2b00fad3f2a3859ccae41eee12ab44434813a371c2f3003b4f2419e5eecb4832"}, + {file = "winrt_Windows.Foundation-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:686619932b2a2c689cbebc7f5196437a45fd2056656ef130bb10240bb111086a"}, + {file = "winrt_Windows.Foundation-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b38dcb83fe82a7da9a57d7d5ad5deb09503b5be6d9357a9fd3016ca31673805d"}, + {file = "winrt_Windows.Foundation-2.3.0-cp39-cp39-win32.whl", hash = "sha256:2d6922de4dc38061b86d314c7319d7c6bd78a52d64ee0c93eb81474bddb499bc"}, + {file = "winrt_Windows.Foundation-2.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:1513e43adff3779d2f611d8bdf9350ac1a7c04389e9e6b1d777c5cd54f46e4fc"}, + {file = "winrt_Windows.Foundation-2.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:c811e4a4f79b947fbbb50f74d34ef6840dd2dd26e0199bd61a4185e48c6a84a8"}, + {file = "winrt_windows_foundation-2.3.0.tar.gz", hash = "sha256:c5766f011c8debbe89b460af4a97d026ca252144e62d7278c9c79c5581ea0c02"}, +] + +[package.dependencies] +winrt-runtime = "2.3.0" + +[package.extras] +all = ["winrt-Windows.Foundation.Collections[all] (==2.3.0)"] + +[[package]] +name = "winrt-windows-foundation-collections" +version = "2.3.0" +description = "Python projection of Windows Runtime (WinRT) APIs" +optional = false +python-versions = "<3.14,>=3.9" +groups = ["main"] +markers = "platform_system == \"Windows\" and python_version == \"3.12\"" +files = [ + {file = "winrt_Windows.Foundation.Collections-2.3.0-cp310-cp310-win32.whl", hash = "sha256:d2fca59eef9582a33c2797b1fda1d5757d66827cc34e6fc1d1c94a5875c4c043"}, + {file = "winrt_Windows.Foundation.Collections-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:d14b47d9137aebad71aa4fde5892673f2fa326f5f4799378cb9f6158b07a9824"}, + {file = "winrt_Windows.Foundation.Collections-2.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:cca5398a4522dffd76decf64a28368cda67e81dc01cad35a9f39cc351af69bdd"}, + {file = "winrt_Windows.Foundation.Collections-2.3.0-cp311-cp311-win32.whl", hash = "sha256:3808af64c95a9b464e8e97f6bec57a8b22168185f1c893f30de69aaf48c85b17"}, + {file = "winrt_Windows.Foundation.Collections-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1e9a3842a39feb965545124abfe79ed726adc5a1fc6a192470a3c5d3ec3f7a74"}, + {file = "winrt_Windows.Foundation.Collections-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:751c2a68fef080dfe0af892ef4cebf317844e4baa786e979028757fe2740fba4"}, + {file = "winrt_Windows.Foundation.Collections-2.3.0-cp312-cp312-win32.whl", hash = "sha256:498c1fc403d3dc7a091aaac92af471615de4f9550d544347cb3b169c197183b5"}, + {file = "winrt_Windows.Foundation.Collections-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:4d1b1cacc159f38d8e6b662f6e7a5c41879a36aa7434c1580d7f948c9037419e"}, + {file = "winrt_Windows.Foundation.Collections-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:398d93b76a2cf70d5e75c1f802e1dd856501e63bc9a31f4510ac59f718951b9e"}, + {file = "winrt_Windows.Foundation.Collections-2.3.0-cp313-cp313-win32.whl", hash = "sha256:1e5f1637e0919c7bb5b11ba1eebbd43bc0ad9600cf887b59fcece0f8a6c0eac3"}, + {file = "winrt_Windows.Foundation.Collections-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:c809a70bc0f93d53c7289a0a86d8869740e09fff0c57318a14401f5c17e0b912"}, + {file = "winrt_Windows.Foundation.Collections-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:269942fe86af06293a2676c8b2dcd5cb1d8ddfe1b5244f11c16e48ae0a5d100f"}, + {file = "winrt_Windows.Foundation.Collections-2.3.0-cp39-cp39-win32.whl", hash = "sha256:936b1c5720b564ec699673198addee97f3bdb790622d24c8fd1b346a9767717c"}, + {file = "winrt_Windows.Foundation.Collections-2.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:905a6ac9cd6b51659a9bba08cf44cfc925f528ef34cdd9c3a6c2632e97804a96"}, + {file = "winrt_Windows.Foundation.Collections-2.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:1d6eac85976bd831e1b8cc479d7f14afa51c27cec5a38e2540077d3400cbd3ef"}, + {file = "winrt_windows_foundation_collections-2.3.0.tar.gz", hash = "sha256:15c997fd6b64ef0400a619319ea3c6851c9c24e31d51b6448ba9bac3616d25a0"}, +] + +[package.dependencies] +winrt-runtime = "2.3.0" + +[package.extras] +all = ["winrt-Windows.Foundation[all] (==2.3.0)"] + +[[package]] +name = "winrt-windows-storage-streams" +version = "2.3.0" +description = "Python projection of Windows Runtime (WinRT) APIs" +optional = false +python-versions = "<3.14,>=3.9" +groups = ["main"] +markers = "platform_system == \"Windows\" and python_version == \"3.12\"" +files = [ + {file = "winrt_Windows.Storage.Streams-2.3.0-cp310-cp310-win32.whl", hash = "sha256:2c0901aee1232e92ed9320644b853d7801a0bdb87790164d56e961cd39910f07"}, + {file = "winrt_Windows.Storage.Streams-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:ba07dc25decffd29aa8603119629c167bd03fa274099e3bad331a4920c292b78"}, + {file = "winrt_Windows.Storage.Streams-2.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:5b60b48460095c50a00a6f7f9b3b780f5bdcb1ec663fc09458201499f93e23ea"}, + {file = "winrt_Windows.Storage.Streams-2.3.0-cp311-cp311-win32.whl", hash = "sha256:8388f37759df64ceef1423ae7dd9275c8a6eb3b8245d400173b4916adc94b5ad"}, + {file = "winrt_Windows.Storage.Streams-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:e5783dbe3694cc3deda594256ebb1088655386959bb834a6bfb7cd763ee87631"}, + {file = "winrt_Windows.Storage.Streams-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:0a487d19c73b82aafa3d5ef889bb35e6e8e2487ca4f16f5446f2445033d5219c"}, + {file = "winrt_Windows.Storage.Streams-2.3.0-cp312-cp312-win32.whl", hash = "sha256:272e87e6c74cb2832261ab33db7966a99e7a2400240cc4f8bf526a80ca054c68"}, + {file = "winrt_Windows.Storage.Streams-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:997bf1a2d52c5f104b172947e571f27d9916a4409b4da592ec3e7f907848dd1a"}, + {file = "winrt_Windows.Storage.Streams-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:d56daa00205c24ede6669d41eb70d6017e0202371d99f8ee2b0b31350ab59bd5"}, + {file = "winrt_Windows.Storage.Streams-2.3.0-cp313-cp313-win32.whl", hash = "sha256:7ac4e46fc5e21d8badc5d41779273c3f5e7196f1cf2df1959b6b70eca1d5d85f"}, + {file = "winrt_Windows.Storage.Streams-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1460027c94c107fcee484997494f3a400f08ee40396f010facb0e72b3b74c457"}, + {file = "winrt_Windows.Storage.Streams-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:e4553a70f5264a7733596802a2991e2414cdcd5e396b9d11ee87be9abae9329e"}, + {file = "winrt_Windows.Storage.Streams-2.3.0-cp39-cp39-win32.whl", hash = "sha256:28e1117e23046e499831af16d11f5e61e6066ed6247ef58b93738702522c29b0"}, + {file = "winrt_Windows.Storage.Streams-2.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:5511dc578f92eb303aee4d3345ee4ffc88aa414564e43e0e3d84ff29427068f0"}, + {file = "winrt_Windows.Storage.Streams-2.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:6f5b3f8af4df08f5bf9329373949236ffaef22d021070278795e56da5326a876"}, + {file = "winrt_windows_storage_streams-2.3.0.tar.gz", hash = "sha256:d2c010beeb1dd7c135ed67ecfaea13440474a7c469e2e9aa2852db27d2063d44"}, +] + +[package.dependencies] +winrt-runtime = "2.3.0" + +[package.extras] +all = ["winrt-Windows.Foundation.Collections[all] (==2.3.0)", "winrt-Windows.Foundation[all] (==2.3.0)", "winrt-Windows.Storage[all] (==2.3.0)", "winrt-Windows.System[all] (==2.3.0)"] + [metadata] lock-version = "2.1" python-versions = ">=3.10,<3.13" -content-hash = "9a446b374e3956cb777d83e4bffde62e6cd910d71ef6f5ccba86c7be13178d22" +content-hash = "0a169371531f16a6388e81a4241de8a43fbd39c83dd64d5f95bc81a954b54fff" diff --git a/pyproject.toml b/pyproject.toml index 22838c9..1028dd2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ disable_error_code = "assignment" [tool.poetry] name = "bitcoin-usb" -version = "3.0.1" +version = "4.0.0" authors = ["andreasgriffin "] license = "GPL-3.0" readme = "README.md" @@ -29,10 +29,13 @@ requests = "^2.32.3" # essential for Jade to work trezor = "^0.13.9" bitcoin-safe-lib = { git = "https://github.com/andreasgriffin/bitcoin-safe-lib.git", branch = "main" } cbor2 = "<5.8.0" # see https://github.com/bitcoin-core/HWI/issues/817 +bleak = "^0.22.3" +jade-client = {git = "https://github.com/Blockstream/Jade.git", rev = "fb2642397ed7afdda08f3d1380ac6ee21164e7e6"} +aioitertools = "^0.13.0" # needed for jade-client [tool.poetry.group.dev.dependencies] pytest = "^8.2.2" - + [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" @@ -63,5 +66,3 @@ known-first-party = ["bitcoin_usb"] [tool.ruff.format] # Ruff formatter is Black-compatible; keep defaults or tweak here. - -