plugin.py (29046B)
1 #!/usr/bin/env python 2 # 3 # Electrum - lightweight Bitcoin client 4 # Copyright (C) 2015 Thomas Voegtlin 5 # 6 # Permission is hereby granted, free of charge, to any person 7 # obtaining a copy of this software and associated documentation files 8 # (the "Software"), to deal in the Software without restriction, 9 # including without limitation the rights to use, copy, modify, merge, 10 # publish, distribute, sublicense, and/or sell copies of the Software, 11 # and to permit persons to whom the Software is furnished to do so, 12 # subject to the following conditions: 13 # 14 # The above copyright notice and this permission notice shall be 15 # included in all copies or substantial portions of the Software. 16 # 17 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 18 # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 19 # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 20 # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS 21 # BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 22 # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 23 # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 # SOFTWARE. 25 import os 26 import pkgutil 27 import importlib.util 28 import time 29 import threading 30 import sys 31 from typing import (NamedTuple, Any, Union, TYPE_CHECKING, Optional, Tuple, 32 Dict, Iterable, List, Sequence, Callable, TypeVar) 33 import concurrent 34 from concurrent import futures 35 from functools import wraps, partial 36 37 from .i18n import _ 38 from .util import (profiler, DaemonThread, UserCancelled, ThreadJob, UserFacingException) 39 from . import bip32 40 from . import plugins 41 from .simple_config import SimpleConfig 42 from .logging import get_logger, Logger 43 44 if TYPE_CHECKING: 45 from .plugins.hw_wallet import HW_PluginBase, HardwareClientBase, HardwareHandlerBase 46 from .keystore import Hardware_KeyStore 47 from .wallet import Abstract_Wallet 48 49 50 _logger = get_logger(__name__) 51 plugin_loaders = {} 52 hook_names = set() 53 hooks = {} 54 55 56 class Plugins(DaemonThread): 57 58 LOGGING_SHORTCUT = 'p' 59 60 @profiler 61 def __init__(self, config: SimpleConfig, gui_name): 62 DaemonThread.__init__(self) 63 self.setName('Plugins') 64 self.pkgpath = os.path.dirname(plugins.__file__) 65 self.config = config 66 self.hw_wallets = {} 67 self.plugins = {} # type: Dict[str, BasePlugin] 68 self.gui_name = gui_name 69 self.descriptions = {} 70 self.device_manager = DeviceMgr(config) 71 self.load_plugins() 72 self.add_jobs(self.device_manager.thread_jobs()) 73 self.start() 74 75 def load_plugins(self): 76 for loader, name, ispkg in pkgutil.iter_modules([self.pkgpath]): 77 full_name = f'electrum.plugins.{name}' 78 spec = importlib.util.find_spec(full_name) 79 if spec is None: # pkgutil found it but importlib can't ?! 80 raise Exception(f"Error pre-loading {full_name}: no spec") 81 try: 82 module = importlib.util.module_from_spec(spec) 83 # sys.modules needs to be modified for relative imports to work 84 # see https://stackoverflow.com/a/50395128 85 sys.modules[spec.name] = module 86 spec.loader.exec_module(module) 87 except Exception as e: 88 raise Exception(f"Error pre-loading {full_name}: {repr(e)}") from e 89 d = module.__dict__ 90 gui_good = self.gui_name in d.get('available_for', []) 91 if not gui_good: 92 continue 93 details = d.get('registers_wallet_type') 94 if details: 95 self.register_wallet_type(name, gui_good, details) 96 details = d.get('registers_keystore') 97 if details: 98 self.register_keystore(name, gui_good, details) 99 self.descriptions[name] = d 100 if not d.get('requires_wallet_type') and self.config.get('use_' + name): 101 try: 102 self.load_plugin(name) 103 except BaseException as e: 104 self.logger.exception(f"cannot initialize plugin {name}: {e}") 105 106 def get(self, name): 107 return self.plugins.get(name) 108 109 def count(self): 110 return len(self.plugins) 111 112 def load_plugin(self, name) -> 'BasePlugin': 113 if name in self.plugins: 114 return self.plugins[name] 115 full_name = f'electrum.plugins.{name}.{self.gui_name}' 116 spec = importlib.util.find_spec(full_name) 117 if spec is None: 118 raise RuntimeError("%s implementation for %s plugin not found" 119 % (self.gui_name, name)) 120 try: 121 module = importlib.util.module_from_spec(spec) 122 spec.loader.exec_module(module) 123 plugin = module.Plugin(self, self.config, name) 124 except Exception as e: 125 raise Exception(f"Error loading {name} plugin: {repr(e)}") from e 126 self.add_jobs(plugin.thread_jobs()) 127 self.plugins[name] = plugin 128 self.logger.info(f"loaded {name}") 129 return plugin 130 131 def close_plugin(self, plugin): 132 self.remove_jobs(plugin.thread_jobs()) 133 134 def enable(self, name: str) -> 'BasePlugin': 135 self.config.set_key('use_' + name, True, True) 136 p = self.get(name) 137 if p: 138 return p 139 return self.load_plugin(name) 140 141 def disable(self, name: str) -> None: 142 self.config.set_key('use_' + name, False, True) 143 p = self.get(name) 144 if not p: 145 return 146 self.plugins.pop(name) 147 p.close() 148 self.logger.info(f"closed {name}") 149 150 def toggle(self, name: str) -> Optional['BasePlugin']: 151 p = self.get(name) 152 return self.disable(name) if p else self.enable(name) 153 154 def is_available(self, name: str, wallet: 'Abstract_Wallet') -> bool: 155 d = self.descriptions.get(name) 156 if not d: 157 return False 158 deps = d.get('requires', []) 159 for dep, s in deps: 160 try: 161 __import__(dep) 162 except ImportError as e: 163 self.logger.warning(f'Plugin {name} unavailable: {repr(e)}') 164 return False 165 requires = d.get('requires_wallet_type', []) 166 return not requires or wallet.wallet_type in requires 167 168 def get_hardware_support(self): 169 out = [] 170 for name, (gui_good, details) in self.hw_wallets.items(): 171 if gui_good: 172 try: 173 p = self.get_plugin(name) 174 if p.is_enabled(): 175 out.append(HardwarePluginToScan(name=name, 176 description=details[2], 177 plugin=p, 178 exception=None)) 179 except Exception as e: 180 self.logger.exception(f"cannot load plugin for: {name}") 181 out.append(HardwarePluginToScan(name=name, 182 description=details[2], 183 plugin=None, 184 exception=e)) 185 return out 186 187 def register_wallet_type(self, name, gui_good, wallet_type): 188 from .wallet import register_wallet_type, register_constructor 189 self.logger.info(f"registering wallet type {(wallet_type, name)}") 190 def loader(): 191 plugin = self.get_plugin(name) 192 register_constructor(wallet_type, plugin.wallet_class) 193 register_wallet_type(wallet_type) 194 plugin_loaders[wallet_type] = loader 195 196 def register_keystore(self, name, gui_good, details): 197 from .keystore import register_keystore 198 def dynamic_constructor(d): 199 return self.get_plugin(name).keystore_class(d) 200 if details[0] == 'hardware': 201 self.hw_wallets[name] = (gui_good, details) 202 self.logger.info(f"registering hardware {name}: {details}") 203 register_keystore(details[1], dynamic_constructor) 204 205 def get_plugin(self, name: str) -> 'BasePlugin': 206 if name not in self.plugins: 207 self.load_plugin(name) 208 return self.plugins[name] 209 210 def run(self): 211 while self.is_running(): 212 time.sleep(0.1) 213 self.run_jobs() 214 self.on_stop() 215 216 217 def hook(func): 218 hook_names.add(func.__name__) 219 return func 220 221 def run_hook(name, *args): 222 results = [] 223 f_list = hooks.get(name, []) 224 for p, f in f_list: 225 if p.is_enabled(): 226 try: 227 r = f(*args) 228 except Exception: 229 _logger.exception(f"Plugin error. plugin: {p}, hook: {name}") 230 r = False 231 if r: 232 results.append(r) 233 234 if results: 235 assert len(results) == 1, results 236 return results[0] 237 238 239 class BasePlugin(Logger): 240 241 def __init__(self, parent, config: 'SimpleConfig', name): 242 self.parent = parent # type: Plugins # The plugins object 243 self.name = name 244 self.config = config 245 self.wallet = None 246 Logger.__init__(self) 247 # add self to hooks 248 for k in dir(self): 249 if k in hook_names: 250 l = hooks.get(k, []) 251 l.append((self, getattr(self, k))) 252 hooks[k] = l 253 254 def __str__(self): 255 return self.name 256 257 def close(self): 258 # remove self from hooks 259 for attr_name in dir(self): 260 if attr_name in hook_names: 261 # found attribute in self that is also the name of a hook 262 l = hooks.get(attr_name, []) 263 try: 264 l.remove((self, getattr(self, attr_name))) 265 except ValueError: 266 # maybe attr name just collided with hook name and was not hook 267 continue 268 hooks[attr_name] = l 269 self.parent.close_plugin(self) 270 self.on_close() 271 272 def on_close(self): 273 pass 274 275 def requires_settings(self) -> bool: 276 return False 277 278 def thread_jobs(self): 279 return [] 280 281 def is_enabled(self): 282 return self.is_available() and self.config.get('use_'+self.name) is True 283 284 def is_available(self): 285 return True 286 287 def can_user_disable(self): 288 return True 289 290 def settings_widget(self, window): 291 raise NotImplementedError() 292 293 def settings_dialog(self, window): 294 raise NotImplementedError() 295 296 297 class DeviceUnpairableError(UserFacingException): pass 298 class HardwarePluginLibraryUnavailable(Exception): pass 299 class CannotAutoSelectDevice(Exception): pass 300 301 302 class Device(NamedTuple): 303 path: Union[str, bytes] 304 interface_number: int 305 id_: str 306 product_key: Any # when using hid, often Tuple[int, int] 307 usage_page: int 308 transport_ui_string: str 309 310 311 class DeviceInfo(NamedTuple): 312 device: Device 313 label: Optional[str] = None 314 initialized: Optional[bool] = None 315 exception: Optional[Exception] = None 316 plugin_name: Optional[str] = None # manufacturer, e.g. "trezor" 317 soft_device_id: Optional[str] = None # if available, used to distinguish same-type hw devices 318 model_name: Optional[str] = None # e.g. "Ledger Nano S" 319 320 321 class HardwarePluginToScan(NamedTuple): 322 name: str 323 description: str 324 plugin: Optional['HW_PluginBase'] 325 exception: Optional[Exception] 326 327 328 PLACEHOLDER_HW_CLIENT_LABELS = {None, "", " "} 329 330 331 # hidapi is not thread-safe 332 # see https://github.com/signal11/hidapi/issues/205#issuecomment-527654560 333 # https://github.com/libusb/hidapi/issues/45 334 # https://github.com/signal11/hidapi/issues/45#issuecomment-4434598 335 # https://github.com/signal11/hidapi/pull/414#issuecomment-445164238 336 # It is not entirely clear to me, exactly what is safe and what isn't, when 337 # using multiple threads... 338 # Hence, we use a single thread for all device communications, including 339 # enumeration. Everything that uses hidapi, libusb, etc, MUST run on 340 # the following thread: 341 _hwd_comms_executor = concurrent.futures.ThreadPoolExecutor( 342 max_workers=1, 343 thread_name_prefix='hwd_comms_thread' 344 ) 345 346 347 T = TypeVar('T') 348 349 350 def run_in_hwd_thread(func: Callable[[], T]) -> T: 351 if threading.current_thread().name.startswith("hwd_comms_thread"): 352 return func() 353 else: 354 fut = _hwd_comms_executor.submit(func) 355 return fut.result() 356 #except (concurrent.futures.CancelledError, concurrent.futures.TimeoutError) as e: 357 358 359 def runs_in_hwd_thread(func): 360 @wraps(func) 361 def wrapper(*args, **kwargs): 362 return run_in_hwd_thread(partial(func, *args, **kwargs)) 363 return wrapper 364 365 366 def assert_runs_in_hwd_thread(): 367 if not threading.current_thread().name.startswith("hwd_comms_thread"): 368 raise Exception("must only be called from HWD communication thread") 369 370 371 class DeviceMgr(ThreadJob): 372 '''Manages hardware clients. A client communicates over a hardware 373 channel with the device. 374 375 In addition to tracking device HID IDs, the device manager tracks 376 hardware wallets and manages wallet pairing. A HID ID may be 377 paired with a wallet when it is confirmed that the hardware device 378 matches the wallet, i.e. they have the same master public key. A 379 HID ID can be unpaired if e.g. it is wiped. 380 381 Because of hotplugging, a wallet must request its client 382 dynamically each time it is required, rather than caching it 383 itself. 384 385 The device manager is shared across plugins, so just one place 386 does hardware scans when needed. By tracking HID IDs, if a device 387 is plugged into a different port the wallet is automatically 388 re-paired. 389 390 Wallets are informed on connect / disconnect events. It must 391 implement connected(), disconnected() callbacks. Being connected 392 implies a pairing. Callbacks can happen in any thread context, 393 and we do them without holding the lock. 394 395 Confusingly, the HID ID (serial number) reported by the HID system 396 doesn't match the device ID reported by the device itself. We use 397 the HID IDs. 398 399 This plugin is thread-safe. Currently only devices supported by 400 hidapi are implemented.''' 401 402 def __init__(self, config: SimpleConfig): 403 ThreadJob.__init__(self) 404 # Keyed by xpub. The value is the device id 405 # has been paired, and None otherwise. Needs self.lock. 406 self.xpub_ids = {} # type: Dict[str, str] 407 # A list of clients. The key is the client, the value is 408 # a (path, id_) pair. Needs self.lock. 409 self.clients = {} # type: Dict[HardwareClientBase, Tuple[Union[str, bytes], str]] 410 # What we recognise. (vendor_id, product_id) -> Plugin 411 self._recognised_hardware = {} # type: Dict[Tuple[int, int], HW_PluginBase] 412 self._recognised_vendor = {} # type: Dict[int, HW_PluginBase] # vendor_id -> Plugin 413 # Custom enumerate functions for devices we don't know about. 414 self._enumerate_func = set() # Needs self.lock. 415 416 self.lock = threading.RLock() 417 418 self.config = config 419 420 def thread_jobs(self): 421 # Thread job to handle device timeouts 422 return [self] 423 424 def run(self): 425 '''Handle device timeouts. Runs in the context of the Plugins 426 thread.''' 427 with self.lock: 428 clients = list(self.clients.keys()) 429 cutoff = time.time() - self.config.get_session_timeout() 430 for client in clients: 431 client.timeout(cutoff) 432 433 def register_devices(self, device_pairs, *, plugin: 'HW_PluginBase'): 434 for pair in device_pairs: 435 self._recognised_hardware[pair] = plugin 436 437 def register_vendor_ids(self, vendor_ids: Iterable[int], *, plugin: 'HW_PluginBase'): 438 for vendor_id in vendor_ids: 439 self._recognised_vendor[vendor_id] = plugin 440 441 def register_enumerate_func(self, func): 442 with self.lock: 443 self._enumerate_func.add(func) 444 445 @runs_in_hwd_thread 446 def create_client(self, device: 'Device', handler: Optional['HardwareHandlerBase'], 447 plugin: 'HW_PluginBase') -> Optional['HardwareClientBase']: 448 # Get from cache first 449 client = self._client_by_id(device.id_) 450 if client: 451 return client 452 client = plugin.create_client(device, handler) 453 if client: 454 self.logger.info(f"Registering {client}") 455 with self.lock: 456 self.clients[client] = (device.path, device.id_) 457 return client 458 459 def xpub_id(self, xpub): 460 with self.lock: 461 return self.xpub_ids.get(xpub) 462 463 def xpub_by_id(self, id_): 464 with self.lock: 465 for xpub, xpub_id in self.xpub_ids.items(): 466 if xpub_id == id_: 467 return xpub 468 return None 469 470 def unpair_xpub(self, xpub): 471 with self.lock: 472 if xpub not in self.xpub_ids: 473 return 474 _id = self.xpub_ids.pop(xpub) 475 self._close_client(_id) 476 477 def unpair_id(self, id_): 478 xpub = self.xpub_by_id(id_) 479 if xpub: 480 self.unpair_xpub(xpub) 481 else: 482 self._close_client(id_) 483 484 def _close_client(self, id_): 485 with self.lock: 486 client = self._client_by_id(id_) 487 self.clients.pop(client, None) 488 if client: 489 client.close() 490 491 def pair_xpub(self, xpub, id_): 492 with self.lock: 493 self.xpub_ids[xpub] = id_ 494 495 def _client_by_id(self, id_) -> Optional['HardwareClientBase']: 496 with self.lock: 497 for client, (path, client_id) in self.clients.items(): 498 if client_id == id_: 499 return client 500 return None 501 502 def client_by_id(self, id_, *, scan_now: bool = True) -> Optional['HardwareClientBase']: 503 '''Returns a client for the device ID if one is registered. If 504 a device is wiped or in bootloader mode pairing is impossible; 505 in such cases we communicate by device ID and not wallet.''' 506 if scan_now: 507 self.scan_devices() 508 return self._client_by_id(id_) 509 510 @runs_in_hwd_thread 511 def client_for_keystore(self, plugin: 'HW_PluginBase', handler: Optional['HardwareHandlerBase'], 512 keystore: 'Hardware_KeyStore', 513 force_pair: bool, *, 514 devices: Sequence['Device'] = None, 515 allow_user_interaction: bool = True) -> Optional['HardwareClientBase']: 516 self.logger.info("getting client for keystore") 517 if handler is None: 518 raise Exception(_("Handler not found for") + ' ' + plugin.name + '\n' + _("A library is probably missing.")) 519 handler.update_status(False) 520 if devices is None: 521 devices = self.scan_devices() 522 xpub = keystore.xpub 523 derivation = keystore.get_derivation_prefix() 524 assert derivation is not None 525 client = self.client_by_xpub(plugin, xpub, handler, devices) 526 if client is None and force_pair: 527 try: 528 info = self.select_device(plugin, handler, keystore, devices, 529 allow_user_interaction=allow_user_interaction) 530 except CannotAutoSelectDevice: 531 pass 532 else: 533 client = self.force_pair_xpub(plugin, handler, info, xpub, derivation) 534 if client: 535 handler.update_status(True) 536 if client: 537 # note: if select_device was called, we might also update label etc here: 538 keystore.opportunistically_fill_in_missing_info_from_device(client) 539 self.logger.info("end client for keystore") 540 return client 541 542 def client_by_xpub(self, plugin: 'HW_PluginBase', xpub, handler: 'HardwareHandlerBase', 543 devices: Sequence['Device']) -> Optional['HardwareClientBase']: 544 _id = self.xpub_id(xpub) 545 client = self._client_by_id(_id) 546 if client: 547 # An unpaired client might have another wallet's handler 548 # from a prior scan. Replace to fix dialog parenting. 549 client.handler = handler 550 return client 551 552 for device in devices: 553 if device.id_ == _id: 554 return self.create_client(device, handler, plugin) 555 556 def force_pair_xpub(self, plugin: 'HW_PluginBase', handler: 'HardwareHandlerBase', 557 info: 'DeviceInfo', xpub, derivation) -> Optional['HardwareClientBase']: 558 # The wallet has not been previously paired, so let the user 559 # choose an unpaired device and compare its first address. 560 xtype = bip32.xpub_type(xpub) 561 client = self._client_by_id(info.device.id_) 562 if client and client.is_pairable(): 563 # See comment above for same code 564 client.handler = handler 565 # This will trigger a PIN/passphrase entry request 566 try: 567 client_xpub = client.get_xpub(derivation, xtype) 568 except (UserCancelled, RuntimeError): 569 # Bad / cancelled PIN / passphrase 570 client_xpub = None 571 if client_xpub == xpub: 572 self.pair_xpub(xpub, info.device.id_) 573 return client 574 575 # The user input has wrong PIN or passphrase, or cancelled input, 576 # or it is not pairable 577 raise DeviceUnpairableError( 578 _('Electrum cannot pair with your {}.\n\n' 579 'Before you request bitcoins to be sent to addresses in this ' 580 'wallet, ensure you can pair with your device, or that you have ' 581 'its seed (and passphrase, if any). Otherwise all bitcoins you ' 582 'receive will be unspendable.').format(plugin.device)) 583 584 def unpaired_device_infos(self, handler: Optional['HardwareHandlerBase'], plugin: 'HW_PluginBase', 585 devices: Sequence['Device'] = None, 586 include_failing_clients=False) -> List['DeviceInfo']: 587 '''Returns a list of DeviceInfo objects: one for each connected, 588 unpaired device accepted by the plugin.''' 589 if not plugin.libraries_available: 590 message = plugin.get_library_not_available_message() 591 raise HardwarePluginLibraryUnavailable(message) 592 if devices is None: 593 devices = self.scan_devices() 594 devices = [dev for dev in devices if not self.xpub_by_id(dev.id_)] 595 infos = [] 596 for device in devices: 597 if not plugin.can_recognize_device(device): 598 continue 599 try: 600 client = self.create_client(device, handler, plugin) 601 if not client: 602 continue 603 label = client.label() 604 is_initialized = client.is_initialized() 605 soft_device_id = client.get_soft_device_id() 606 model_name = client.device_model_name() 607 except Exception as e: 608 self.logger.error(f'failed to create client for {plugin.name} at {device.path}: {repr(e)}') 609 if include_failing_clients: 610 infos.append(DeviceInfo(device=device, exception=e, plugin_name=plugin.name)) 611 continue 612 infos.append(DeviceInfo(device=device, 613 label=label, 614 initialized=is_initialized, 615 plugin_name=plugin.name, 616 soft_device_id=soft_device_id, 617 model_name=model_name)) 618 619 return infos 620 621 def select_device(self, plugin: 'HW_PluginBase', handler: 'HardwareHandlerBase', 622 keystore: 'Hardware_KeyStore', devices: Sequence['Device'] = None, 623 *, allow_user_interaction: bool = True) -> 'DeviceInfo': 624 """Select the device to use for keystore.""" 625 # ideally this should not be called from the GUI thread... 626 # assert handler.get_gui_thread() != threading.current_thread(), 'must not be called from GUI thread' 627 while True: 628 infos = self.unpaired_device_infos(handler, plugin, devices) 629 if infos: 630 break 631 if not allow_user_interaction: 632 raise CannotAutoSelectDevice() 633 msg = _('Please insert your {}').format(plugin.device) 634 if keystore.label: 635 msg += ' ({})'.format(keystore.label) 636 msg += '. {}\n\n{}'.format( 637 _('Verify the cable is connected and that ' 638 'no other application is using it.'), 639 _('Try to connect again?') 640 ) 641 if not handler.yes_no_question(msg): 642 raise UserCancelled() 643 devices = None 644 645 # select device automatically. (but only if we have reasonable expectation it is the correct one) 646 # method 1: select device by id 647 if keystore.soft_device_id: 648 for info in infos: 649 if info.soft_device_id == keystore.soft_device_id: 650 return info 651 # method 2: select device by label 652 # but only if not a placeholder label and only if there is no collision 653 device_labels = [info.label for info in infos] 654 if (keystore.label not in PLACEHOLDER_HW_CLIENT_LABELS 655 and device_labels.count(keystore.label) == 1): 656 for info in infos: 657 if info.label == keystore.label: 658 return info 659 # method 3: if there is only one device connected, and we don't have useful label/soft_device_id 660 # saved for keystore anyway, select it 661 if (len(infos) == 1 662 and keystore.label in PLACEHOLDER_HW_CLIENT_LABELS 663 and keystore.soft_device_id is None): 664 return infos[0] 665 666 if not allow_user_interaction: 667 raise CannotAutoSelectDevice() 668 # ask user to select device manually 669 msg = _("Please select which {} device to use:").format(plugin.device) 670 descriptions = ["{label} ({maybe_model}{init}, {transport})" 671 .format(label=info.label or _("An unnamed {}").format(info.plugin_name), 672 init=(_("initialized") if info.initialized else _("wiped")), 673 transport=info.device.transport_ui_string, 674 maybe_model=f"{info.model_name}, " if info.model_name else "") 675 for info in infos] 676 c = handler.query_choice(msg, descriptions) 677 if c is None: 678 raise UserCancelled() 679 info = infos[c] 680 # note: updated label/soft_device_id will be saved after pairing succeeds 681 return info 682 683 @runs_in_hwd_thread 684 def _scan_devices_with_hid(self) -> List['Device']: 685 try: 686 import hid 687 except ImportError: 688 return [] 689 690 devices = [] 691 for d in hid.enumerate(0, 0): 692 vendor_id = d['vendor_id'] 693 product_key = (vendor_id, d['product_id']) 694 plugin = None 695 if product_key in self._recognised_hardware: 696 plugin = self._recognised_hardware[product_key] 697 elif vendor_id in self._recognised_vendor: 698 plugin = self._recognised_vendor[vendor_id] 699 if plugin: 700 device = plugin.create_device_from_hid_enumeration(d, product_key=product_key) 701 if device: 702 devices.append(device) 703 return devices 704 705 @runs_in_hwd_thread 706 @profiler 707 def scan_devices(self) -> Sequence['Device']: 708 self.logger.info("scanning devices...") 709 710 # First see what's connected that we know about 711 devices = self._scan_devices_with_hid() 712 713 # Let plugin handlers enumerate devices we don't know about 714 with self.lock: 715 enumerate_funcs = list(self._enumerate_func) 716 for f in enumerate_funcs: 717 try: 718 new_devices = f() 719 except BaseException as e: 720 self.logger.error('custom device enum failed. func {}, error {}' 721 .format(str(f), repr(e))) 722 else: 723 devices.extend(new_devices) 724 725 # find out what was disconnected 726 pairs = [(dev.path, dev.id_) for dev in devices] 727 disconnected_clients = [] 728 with self.lock: 729 connected = {} 730 for client, pair in self.clients.items(): 731 if pair in pairs and client.has_usable_connection_with_device(): 732 connected[client] = pair 733 else: 734 disconnected_clients.append((client, pair[1])) 735 self.clients = connected 736 737 # Unpair disconnected devices 738 for client, id_ in disconnected_clients: 739 self.unpair_id(id_) 740 if client.handler: 741 client.handler.update_status(False) 742 743 return devices