From 58823763eab3d51d0904fb0de9c9c9e32a788d80 Mon Sep 17 00:00:00 2001 From: "Peter F. Patel-Schneider" Date: Thu, 17 Sep 2020 10:56:54 -0400 Subject: [PATCH] ui: handle wired devices --- lib/hidapi/udev.py | 8 ++- lib/logitech_receiver/base.py | 2 +- lib/logitech_receiver/base_usb.py | 2 +- lib/logitech_receiver/device.py | 39 ++++++++++++- lib/solaar/listener.py | 21 +++++-- lib/solaar/ui/config_panel.py | 4 +- lib/solaar/ui/tray.py | 40 +++++++------- lib/solaar/ui/window.py | 91 +++++++++++++++++-------------- 8 files changed, 133 insertions(+), 74 deletions(-) diff --git a/lib/hidapi/udev.py b/lib/hidapi/udev.py index b5ace0fe..5e67a3e1 100644 --- a/lib/hidapi/udev.py +++ b/lib/hidapi/udev.py @@ -55,6 +55,7 @@ DeviceInfo = namedtuple( 'product', 'interface', 'driver', + 'isDevice', ] ) del namedtuple @@ -89,6 +90,7 @@ def _match(action, device, filter): product_id = filter.get('product_id') interface_number = filter.get('usb_interface') hid_driver = filter.get('hid_driver') + isDevice = filter.get('isDevice') usb_device = device.find_parent('usb', 'usb_device') # print ("* parent", action, device, "usb:", usb_device) @@ -134,7 +136,8 @@ def _match(action, device, filter): manufacturer=attrs.get('manufacturer'), product=attrs.get('product'), interface=usb_interface, - driver=hid_driver_name + driver=hid_driver_name, + isDevice=isDevice ) return d_info @@ -150,7 +153,8 @@ def _match(action, device, filter): manufacturer=None, product=None, interface=None, - driver=None + driver=None, + isDevice=isDevice ) return d_info diff --git a/lib/logitech_receiver/base.py b/lib/logitech_receiver/base.py index b931b834..b3871308 100644 --- a/lib/logitech_receiver/base.py +++ b/lib/logitech_receiver/base.py @@ -104,7 +104,7 @@ def wired_devices(): def notify_on_receivers_glib(callback): """Watch for matching devices and notifies the callback on the GLib thread.""" - _hid.monitor_glib(callback, *_RECEIVER_USB_IDS) + _hid.monitor_glib(callback, *_RECEIVER_USB_IDS, *_WIRED_DEVICE_IDS) # diff --git a/lib/logitech_receiver/base_usb.py b/lib/logitech_receiver/base_usb.py index 2059ecab..1e56ab3d 100644 --- a/lib/logitech_receiver/base_usb.py +++ b/lib/logitech_receiver/base_usb.py @@ -99,7 +99,7 @@ _ex100_receiver = lambda product_id: { 'ex100_27mhz_wpid_fix': True } -_wired_device = lambda product_id: {'vendor_id': 0x046d, 'product_id': product_id, 'usb_interface': 2} +_wired_device = lambda product_id: {'vendor_id': 0x046d, 'product_id': product_id, 'usb_interface': 2, 'isDevice': True} # standard Unifying receivers (marked with the orange Unifying logo) UNIFYING_RECEIVER_C52B = _unifying_receiver(0xc52b) diff --git a/lib/logitech_receiver/device.py b/lib/logitech_receiver/device.py index 601ea329..0a66eb9b 100644 --- a/lib/logitech_receiver/device.py +++ b/lib/logitech_receiver/device.py @@ -1,5 +1,7 @@ from __future__ import absolute_import, division, print_function, unicode_literals +import errno as _errno + from logging import INFO as _INFO from logging import getLogger @@ -31,6 +33,7 @@ class Device(object): def __init__(self, receiver, number, link_notification=None, info=None): assert receiver or info self.receiver = receiver + self.may_unpair = False self.isDevice = True # some devices act as receiver so we need a property to distinguish them if receiver: @@ -153,6 +156,7 @@ class Device(object): self.handle = _hid.open_path(self.path) self.product_id = info.product_id self._serial = ''.join(info.serial.split('-')).upper() + self.online = True if self._protocol is not None: self.features = None if self._protocol < 2.0 else _hidpp20.FeaturesArray(self) @@ -182,7 +186,7 @@ class Device(object): # if _log.isEnabledFor(_DEBUG): # _log.debug("device %d codename %s", self.number, self._codename) else: - self._codename = '? (%s)' % self.wpid + self._codename = '? (%s)' % (self.wpid or self.product_id) return self._codename @property @@ -190,7 +194,7 @@ class Device(object): if not self._name: if self.online and self.protocol >= 2.0: self._name = _hidpp20.get_name(self) - return self._name or self.codename or ('Unknown device %s' % self.wpid) + return self._name or self.codename or ('Unknown device %s' % (self.wpid or self.product_id)) @property def kind(self): @@ -369,7 +373,10 @@ class Device(object): def __hash__(self): return self.wpid.__hash__() - __bool__ = __nonzero__ = lambda self: self.wpid is not None and self.number in self.receiver + def __bool__(self): + return self.wpid is not None and self.number in self.receiver if self.receiver else self.handle is not None + + __nonzero__ = __bool__ def __str__(self): return '' % ( @@ -377,3 +384,29 @@ class Device(object): ) __unicode__ = __repr__ = __str__ + + def notify_devices(self): # no need to notify, as there are none + pass + + @classmethod + def open(self, device_info): + """Opens a Logitech Device found attached to the machine, by Linux device path. + :returns: An open file handle for the found receiver, or ``None``. + """ + try: + handle = _base.open_path(device_info.path) + if handle: + return Device(None, 0, info=device_info) + except OSError as e: + _log.exception('open %s', device_info) + if e.errno == _errno.EACCES: + raise + except Exception: + _log.exception('open %s', device_info) + + def close(self): + handle, self.handle = self.handle, None + return (handle and _base.close(handle)) + + def __del__(self): + self.close() diff --git a/lib/solaar/listener.py b/lib/solaar/listener.py index 56417050..eef9a3b6 100644 --- a/lib/solaar/listener.py +++ b/lib/solaar/listener.py @@ -26,15 +26,16 @@ from logging import INFO as _INFO from logging import WARNING as _WARNING from logging import getLogger -from logitech_receiver import Receiver +from logitech_receiver import Device, Receiver from logitech_receiver import base as _base from logitech_receiver import listener as _listener from logitech_receiver import notifications as _notifications from logitech_receiver import status as _status -from solaar.i18n import _ from . import configuration +# from solaar.i18n import _ + _log = getLogger(__name__) del getLogger @@ -94,7 +95,7 @@ class ReceiverListener(_listener.EventsListener): # make sure to clean up in _all_listeners _all_listeners.pop(r.path, None) - r.status = _('The receiver was unplugged.') + # this causes problems but what is it doing (pfps) - r.status = _('The receiver was unplugged.') if r: try: r.close() @@ -159,7 +160,7 @@ class ReceiverListener(_listener.EventsListener): self.status_changed_callback(device, alert, reason) return - assert device.receiver == self.receiver + # not true for wired devices - assert device.receiver == self.receiver if not device: # Device was unpaired, and isn't valid anymore. # We replace it with a ghost so that the UI has something to work @@ -270,11 +271,19 @@ _all_listeners = {} def _start(device_info): assert _status_callback - receiver = Receiver.open(device_info) + isDevice = device_info.isDevice + if not isDevice: + receiver = Receiver.open(device_info) + else: + receiver = Device.open(device_info) + configuration.attach_to(receiver) + if receiver: rl = ReceiverListener(receiver, _status_callback) rl.start() _all_listeners[device_info.path] = rl + if isDevice: # (wired) devices start as active + receiver.status.changed(True) return rl _log.warn('failed to open %s', device_info) @@ -288,6 +297,8 @@ def start_all(): _log.info('starting receiver listening threads') for device_info in _base.receivers(): _process_receiver_event('add', device_info) + for device_info in _base.wired_devices(): + _process_receiver_event('add', device_info) def stop_all(): diff --git a/lib/solaar/ui/config_panel.py b/lib/solaar/ui/config_panel.py index 4bd5cbf1..ddcc3875 100644 --- a/lib/solaar/ui/config_panel.py +++ b/lib/solaar/ui/config_panel.py @@ -509,7 +509,7 @@ def create(): def update(device, is_online=None): assert _box is not None assert device - device_id = (device.receiver.path, device.number) + device_id = (device.receiver.path if device.receiver else device.path, device.number) if is_online is None: is_online = bool(device.online) @@ -541,7 +541,7 @@ def clean(device): Needed after the device has been unpaired. """ assert _box is not None - device_id = (device.receiver.path, device.number) + device_id = (device.receiver.path if device.receiver else device.path, device.number) for k in list(_items.keys()): if k[0:2] == device_id: _box.remove(_items[k]) diff --git a/lib/solaar/ui/tray.py b/lib/solaar/ui/tray.py index 3ce6290b..6e08f78c 100644 --- a/lib/solaar/ui/tray.py +++ b/lib/solaar/ui/tray.py @@ -351,28 +351,29 @@ def _pick_device_with_lowest_battery(): def _add_device(device): assert device - assert device.receiver - receiver_path = device.receiver.path - assert receiver_path + # not true for wired devices - assert device.receiver + receiver_path = device.receiver.path if device.receiver is not None else device.path + # not true for wired devices - assert receiver_path - index = None + index = 0 for idx, (path, _ignore, _ignore, _ignore) in enumerate(_devices_info): if path == receiver_path: # the first entry matching the receiver serial should be for the receiver itself index = idx + 1 break - assert index is not None + # assert index is not None - # proper ordering (according to device.number) for a receiver's devices - while True: - path, number, _ignore, _ignore = _devices_info[index] - if path == _RECEIVER_SEPARATOR[0]: - break - assert path == receiver_path - assert number != device.number - if number > device.number: - break - index = index + 1 + if device.receiver: + # proper ordering (according to device.number) for a receiver's devices + while True: + path, number, _ignore, _ignore = _devices_info[index] + if path == _RECEIVER_SEPARATOR[0]: + break + assert path == receiver_path + assert number != device.number + if number > device.number: + break + index = index + 1 new_device_info = (receiver_path, device.number, device.name, device.status) assert len(new_device_info) == len(_RECEIVER_SEPARATOR) @@ -381,7 +382,7 @@ def _add_device(device): # label_prefix = b'\xE2\x94\x84 '.decode('utf-8') label_prefix = ' ' - new_menu_item = Gtk.ImageMenuItem.new_with_label(label_prefix + device.name) + new_menu_item = Gtk.ImageMenuItem.new_with_label((label_prefix if device.number else '') + device.name) new_menu_item.set_image(Gtk.Image()) new_menu_item.show_all() new_menu_item.connect('activate', _window_popup, receiver_path, device.number) @@ -519,7 +520,7 @@ def update(device=None): else: # peripheral is_paired = bool(device) - receiver_path = device.receiver.path + receiver_path = device.receiver.path if device.receiver is not None else device.path index = None for idx, (path, number, _ignore, _ignore) in enumerate(_devices_info): if path == receiver_path and number == device.number: @@ -529,9 +530,8 @@ def update(device=None): if index is None: index = _add_device(device) _update_menu_item(index, device) - else: - # was just unpaired - if index: + else: # was just unpaired or unplugged + if index is not None: _remove_device(index) menu_items = _menu.get_children() diff --git a/lib/solaar/ui/window.py b/lib/solaar/ui/window.py index a1dec5fb..3dac44de 100644 --- a/lib/solaar/ui/window.py +++ b/lib/solaar/ui/window.py @@ -433,6 +433,9 @@ def _device_row(receiver_path, device_number, device=None): assert device_number is not None receiver_row = _receiver_row(receiver_path, None if device is None else device.receiver) + if receiver_row and device_number == 0: # wired device, receiver row is device row + return receiver_row + item = _model.iter_children(receiver_row) new_child_index = 0 while item: @@ -450,9 +453,10 @@ def _device_row(receiver_path, device_number, device=None): icon_name = _icons.device_icon_name(device.name, device.kind) status_text = None status_icon = None - row_data = ( - receiver_path, device_number, bool(device.online), device.codename, icon_name, status_text, status_icon, device + codename = device.codename if device.codename and device.codename[0] != '?' else ( + device.name.split()[0] if device.name.split() else device.codename ) + row_data = (receiver_path, device_number, bool(device.online), codename, icon_name, status_text, status_icon, device) assert len(row_data) == len(_TREE_SEPATATOR) if _log.isEnabledFor(_DEBUG): _log.debug('new device row %s at index %d', row_data, new_child_index) @@ -533,7 +537,10 @@ def _update_details(button): else: # yield ('Codename', device.codename) yield (_('Index'), device.number) - yield (_('Wireless PID'), device.wpid) + if device.wpid: + yield (_('Wireless PID'), device.wpid) + if device.product_id: + yield (_('USB id'), '046d:' + device.product_id) hid_version = device.protocol yield (_('Protocol'), 'HID++ %1.1f' % hid_version if hid_version else _('Unknown')) if read_all and device.polling_rate: @@ -654,6 +661,9 @@ def _update_device_panel(device, panel, buttons, full=False): is_online = bool(device.online) panel.set_sensitive(is_online) + if device.status.get(_K.BATTERY_LEVEL) is None: + device.status.read_battery(device) + battery_level = device.status.get(_K.BATTERY_LEVEL) battery_next_level = device.status.get(_K.BATTERY_NEXT_LEVEL) battery_voltage = device.status.get(_K.BATTERY_VOLTAGE) @@ -736,7 +746,7 @@ def _update_device_panel(device, panel, buttons, full=False): panel._lux.set_visible(False) buttons._pair.set_visible(False) - buttons._unpair.set_sensitive(device.receiver.may_unpair) + buttons._unpair.set_sensitive(device.receiver.may_unpair if device.receiver else False) buttons._unpair.set_visible(True) panel.set_visible(True) @@ -841,14 +851,14 @@ def update(device, need_popup=False): selected_device_id = _find_selected_device_id() - if device.kind is None: + if device.kind is None: # receiver # receiver is_alive = bool(device) item = _receiver_row(device.path, device if is_alive else None) if is_alive and item: was_pairing = bool(_model.get_value(item, _COLUMN.STATUS_ICON)) - is_pairing = bool(device.status.lock_open) + is_pairing = (not device.isDevice) and bool(device.status.lock_open) _model.set_value(item, _COLUMN.STATUS_ICON, 'network-wireless' if is_pairing else _CAN_SET_ROW_NONE) if selected_device_id == (device.path, 0): @@ -862,44 +872,45 @@ def update(device, need_popup=False): _model.remove(item) else: - # peripheral - is_paired = bool(device) - assert device.receiver - assert device.number is not None and device.number > 0, 'invalid device number' + str(device.number) - item = _device_row(device.receiver.path, device.number, device if is_paired else None) - - if is_paired and item: - was_online = _model.get_value(item, _COLUMN.ACTIVE) - is_online = bool(device.online) - _model.set_value(item, _COLUMN.ACTIVE, is_online) - - battery_level = device.status.get(_K.BATTERY_LEVEL) - battery_voltage = device.status.get(_K.BATTERY_VOLTAGE) - if battery_level is None: - _model.set_value(item, _COLUMN.STATUS_TEXT, _CAN_SET_ROW_NONE) - _model.set_value(item, _COLUMN.STATUS_ICON, _CAN_SET_ROW_NONE) - else: - if battery_voltage is not None: - status_text = '%(battery_voltage)dmV' % {'battery_voltage': battery_voltage} - elif isinstance(battery_level, _NamedInt): - status_text = _(str(battery_level)) - else: - status_text = '%(battery_percent)d%%' % {'battery_percent': battery_level} - _model.set_value(item, _COLUMN.STATUS_TEXT, status_text) - - charging = device.status.get(_K.BATTERY_CHARGING) - icon_name = _icons.battery(battery_level, charging) - _model.set_value(item, _COLUMN.STATUS_ICON, icon_name) - - if selected_device_id is None or need_popup: - select(device.receiver.path, device.number) - elif selected_device_id == (device.receiver.path, device.number): - full_update = need_popup or was_online != is_online - _update_info_panel(device, full=full_update) + path = device.receiver.path if device.receiver else device.path + assert device.number is not None and device.number >= 0, 'invalid device number' + str(device.number) + item = _device_row(path, device.number, device if bool(device) else None) + if bool(device) and item: + update_device(device, item, selected_device_id, need_popup) elif item: _model.remove(item) _config_panel.clean(device) # make sure all rows are visible _tree.expand_all() + + +def update_device(device, item, selected_device_id, need_popup): + was_online = _model.get_value(item, _COLUMN.ACTIVE) + is_online = bool(device.online) + _model.set_value(item, _COLUMN.ACTIVE, is_online) + + battery_level = device.status.get(_K.BATTERY_LEVEL) + battery_voltage = device.status.get(_K.BATTERY_VOLTAGE) + if battery_level is None: + _model.set_value(item, _COLUMN.STATUS_TEXT, _CAN_SET_ROW_NONE) + _model.set_value(item, _COLUMN.STATUS_ICON, _CAN_SET_ROW_NONE) + else: + if battery_voltage is not None: + status_text = '%(battery_voltage)dmV' % {'battery_voltage': battery_voltage} + elif isinstance(battery_level, _NamedInt): + status_text = _(str(battery_level)) + else: + status_text = '%(battery_percent)d%%' % {'battery_percent': battery_level} + _model.set_value(item, _COLUMN.STATUS_TEXT, status_text) + + charging = device.status.get(_K.BATTERY_CHARGING) + icon_name = _icons.battery(battery_level, charging) + _model.set_value(item, _COLUMN.STATUS_ICON, icon_name) + + if selected_device_id is None or need_popup: + select(device.receiver.path if device.receiver else device.path, device.number) + elif selected_device_id == (device.receiver.path if device.receiver else device.path, device.number): + full_update = need_popup or was_online != is_online + _update_info_panel(device, full=full_update)