tplugin.py - electrum - Electrum Bitcoin wallet
HTML git clone https://git.parazyd.org/electrum
DIR Log
DIR Files
DIR Refs
DIR Submodules
---
tplugin.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