diff --git a/app/pairing.py b/app/pairing.py index 1fa7c312..2be37f37 100644 --- a/app/pairing.py +++ b/app/pairing.py @@ -64,6 +64,8 @@ class State(object): if event.data == b'\x4A\x00\x01\x00\x00': _l.debug("receiver gave up") self.success = False + # self.success = True + # self.detected_device = self.listener.receiver.devices[1] return True return False @@ -78,5 +80,4 @@ class State(object): return True def unpair(self, device): - _l.debug("unpair %s", device) - self.listener.unpair_device(device) + return self.listener.unpair_device(device) diff --git a/app/receiver.py b/app/receiver.py index 8ad1aa17..0aaf0236 100644 --- a/app/receiver.py +++ b/app/receiver.py @@ -4,7 +4,6 @@ from logging import getLogger as _Logger from struct import pack as _pack -from time import sleep as _sleep from logitech.unifying_receiver import base as _base from logitech.unifying_receiver import api as _api @@ -24,14 +23,22 @@ class _FeaturesArray(object): self.device = device self.features = None self.supported = True + self._check() + + def __del__(self): + self.supported = False + self.device = None def _check(self): if self.supported: if self.features is not None: return True + if self.device.protocol < 2.0: + return False + if self.device.status >= STATUS.CONNECTED: - handle = self.device.handle + handle = int(self.device.handle) try: index = _api.get_feature_index(handle, self.device.number, _api.FEATURE.FEATURE_SET) except _api._FeatureNotSupported: @@ -57,9 +64,13 @@ class _FeaturesArray(object): if index < 0 or index >= len(self.features): raise IndexError + if self.features[index] is None: + # print "features getitem at %d" % index fs_index = self.features.index(_api.FEATURE.FEATURE_SET) - feature = _base.request(self.device.handle, self.device.number, _pack('!BB', fs_index, 0x10), _pack('!B', index)) + # technically fs_function is 0x10 for this call, but we add the index to differentiate possibly conflicting requests + fs_function = 0x10 | (index & 0x0F) + feature = _base.request(self.device.handle, self.device.number, _pack('!BB', fs_index, fs_function), _pack('!B', index)) if feature is not None: self.features[index] = feature[:2] @@ -70,11 +81,13 @@ class _FeaturesArray(object): if value in self.features: return True + # print "features contains %s" % repr(value) for index in range(0, len(self.features)): f = self.features[index] or self.__getitem__(index) assert f is not None if f == value: return True + # we know the features are ordered by value if f > value: break @@ -105,23 +118,19 @@ class _FeaturesArray(object): class DeviceInfo(_api.PairedDevice): """A device attached to the receiver. """ - def __init__(self, listener, number, status=STATUS.UNKNOWN): - super(DeviceInfo, self).__init__(listener.handle, number) - self._features = _FeaturesArray(self) - - self.LOG = _Logger("Device[%d]" % number) - self._listener = listener + def __init__(self, handle, number, status=STATUS.UNKNOWN, status_changed_callback=None): + super(DeviceInfo, self).__init__(handle, number) + self.LOG = _Logger("Device[%d]" % (number)) + self.status_changed_callback = status_changed_callback self._status = status self.props = {} - # read them now, otherwise it it temporarily hang the UI - # if status >= STATUS.CONNECTED: - # n, k, s, f = self.name, self.kind, self.serial, self.firmware + self._features = _FeaturesArray(self) - @property - def receiver(self): - return self._listener.receiver + def __del__(self): + super(ReceiverListener, self).__del__() + self._features.supported = False @property def status(self): @@ -129,14 +138,18 @@ class DeviceInfo(_api.PairedDevice): @status.setter def status(self, new_status): - if new_status != self._status and not (new_status == STATUS.CONNECTED and self._status > new_status): - self.LOG.debug("status %d => %d", self._status, new_status) - urgent = new_status < STATUS.CONNECTED or self._status < STATUS.CONNECTED - self._status = new_status - self._listener.status_changed(self, urgent) - if new_status < STATUS.CONNECTED: self.props.clear() + else: + self._features._check() + self.serial, self.codename, self.name, self.kind + + if new_status != self._status and not (new_status == STATUS.CONNECTED and self._status > new_status): + self.LOG.debug("status %d => %d", self._status, new_status) + self._status = new_status + if self.status_changed_callback: + ui_flags = STATUS.UI_NOTIFY if new_status == STATUS.UNPAIRED else 0 + self.status_changed_callback(self, ui_flags) @property def status_text(self): @@ -165,11 +178,12 @@ class DeviceInfo(_api.PairedDevice): return True if type(status) == tuple: + ui_flags = status[1].pop(PROPS.UI_FLAGS, 0) p = dict(self.props) self.props.update(status[1]) if self.status == status[0]: - if p != self.props: - self._listener.status_changed(self) + if self.status_changed_callback and (ui_flags or p != self.props): + self.status_changed_callback(self, ui_flags) else: self.status = status[0] return True @@ -179,7 +193,7 @@ class DeviceInfo(_api.PairedDevice): return False def __str__(self): - return '' % (self.number, self._name or '?', self._status) + return '' % (self.handle, self.number, self.codename or '?', self._status) # # @@ -201,16 +215,13 @@ _RECEIVER_STATUS_NAME = _FallbackDict( class ReceiverListener(_EventsListener): """Keeps the status of a Unifying Receiver. """ - def __init__(self, receiver, status_changed_callback=None): super(ReceiverListener, self).__init__(receiver.handle, self._events_handler) + self.LOG = _Logger("Receiver[%s]" % receiver.path) + self.receiver = receiver - - self.LOG = _Logger("ReceiverListener(%s)" % receiver.path) - self.events_filter = None self.events_handler = None - self.status_changed_callback = status_changed_callback receiver.kind = receiver.name @@ -223,23 +234,28 @@ class ReceiverListener(_EventsListener): else: self.LOG.warn("initialization failed") - if _base.request(receiver.handle, 0xFF, b'\x80\x02', b'\x02'): - self.LOG.info("triggered device events") - else: - self.LOG.warn("failed to trigger device events") + self.LOG.info("reports %d device(s) paired", len(receiver)) - self.LOG.info("receiver reports %d device(s) paired", len(receiver)) + def __del__(self): + super(ReceiverListener, self).__del__() + self.receiver = None + + def trigger_device_events(self): + if _base.request(int(self._handle), 0xFF, b'\x80\x02', b'\x02'): + self.LOG.info("triggered device events") + return True + self.LOG.warn("failed to trigger device events") def change_status(self, new_status): if new_status != self.receiver.status: self.LOG.debug("status %d => %d", self.receiver.status, new_status) self.receiver.status = new_status self.receiver.status_text = _RECEIVER_STATUS_NAME[new_status] - self.status_changed(None, True) + self.status_changed(None, STATUS.UI_NOTIFY) - def status_changed(self, device=None, urgent=False): + def status_changed(self, device=None, ui_flags=0): if self.status_changed_callback: - self.status_changed_callback(self.receiver, device, urgent) + self.status_changed_callback(self.receiver, device, ui_flags) def _device_status_from(self, event): state_code = ord(event.data[2:3]) & 0xC0 @@ -248,7 +264,7 @@ class ReceiverListener(_EventsListener): STATUS.CONNECTED if state_code == 0x00 else \ None if state is None: - self.LOG.warn("don't know how to handle state code 0x%02X: %s", state_code, event) + self.LOG.warn("failed to identify status of device %d from 0x%02X: %s", event.devnumber, state_code, event) return state def _events_handler(self, event): @@ -256,26 +272,20 @@ class ReceiverListener(_EventsListener): return if event.code == 0x10 and event.data[0:2] == b'\x41\x04': - if event.devnumber in self.receiver.devices: status = self._device_status_from(event) if status is not None: self.receiver.devices[event.devnumber].status = status else: - dev = self.make_device(event) - if dev is None: - self.LOG.warn("failed to make new device from %s", event) - else: - self.receiver.devices[event.devnumber] = dev - self.change_status(STATUS.CONNECTED + len(self.receiver.devices)) + self.make_device(event) return if event.devnumber == 0xFF: if event.code == 0xFF and event.data is None: - # receiver disconnected self.LOG.warn("disconnected") self.receiver.devices = {} self.change_status(STATUS.UNAVAILABLE) + self.receiver = None return elif event.devnumber in self.receiver.devices: dev = self.receiver.devices[event.devnumber] @@ -285,7 +295,7 @@ class ReceiverListener(_EventsListener): if self.events_handler and self.events_handler(event): return - self.LOG.warn("don't know how to handle event %s", event) + # self.LOG.warn("don't know how to handle event %s", event) def make_device(self, event): if event.devnumber < 1 or event.devnumber > self.receiver.max_devices: @@ -294,12 +304,18 @@ class ReceiverListener(_EventsListener): status = self._device_status_from(event) if status is not None: - dev = DeviceInfo(self, event.devnumber, status) + dev = DeviceInfo(self.handle, event.devnumber, status, self.status_changed) self.LOG.info("new device %s", dev) - self.status_changed(dev, True) - return dev - self.LOG.error("failed to identify status of device %d from %s", event.devnumber, event) + self.receiver.devices[event.devnumber] = dev + self.change_status(STATUS.CONNECTED + len(self.receiver.devices)) + + if status == STATUS.CONNECTED: + dev.protocol, dev.name, dev.kind + self.status_changed(dev, STATUS.UI_NOTIFY) + if status == STATUS.CONNECTED: + dev.serial, dev.firmware + return dev def unpair_device(self, device): try: @@ -315,17 +331,16 @@ class ReceiverListener(_EventsListener): return True def __str__(self): - return '' % (self.receiver.path, self.receiver.status) + return '' % (self.receiver.path, int(self.handle), self.receiver.status) @classmethod def open(self, status_changed_callback=None): receiver = _api.Receiver.open() if receiver: + handle = receiver.handle + receiver.handle = _api.ThreadedHandle(handle, receiver.path) rl = ReceiverListener(receiver, status_changed_callback) rl.start() - - while not rl._active: - _sleep(0.1) return rl # diff --git a/app/solaar.py b/app/solaar.py index 3773a510..dda53d52 100644 --- a/app/solaar.py +++ b/app/solaar.py @@ -1,7 +1,7 @@ #!/usr/bin/env python NAME = 'Solaar' -VERSION = '0.7.2' +VERSION = '0.7.3' __author__ = "Daniel Pavel " __version__ = VERSION __license__ = "GPL" @@ -13,6 +13,9 @@ __license__ = "GPL" def _parse_arguments(): import argparse arg_parser = argparse.ArgumentParser(prog=NAME.lower()) + arg_parser.add_argument('-q', '--quiet', + action='store_true', + help='disable all logging, takes precedence over --verbose') arg_parser.add_argument('-v', '--verbose', action='count', default=0, help='increase the logger verbosity (may be repeated)') @@ -30,9 +33,13 @@ def _parse_arguments(): args = arg_parser.parse_args() import logging - log_level = logging.WARNING - 10 * args.verbose - log_format='%(asctime)s %(levelname)8s [%(threadName)s] %(name)s: %(message)s' - logging.basicConfig(level=max(log_level, logging.DEBUG), format=log_format) + if args.quiet: + logging.root.addHandler(logging.NullHandler()) + logging.root.setLevel(logging.CRITICAL) + else: + log_level = logging.WARNING - 10 * args.verbose + log_format='%(asctime)s %(levelname)8s [%(threadName)s] %(name)s: %(message)s' + logging.basicConfig(level=max(log_level, logging.DEBUG), format=log_format) return args @@ -81,14 +88,18 @@ if __name__ == '__main__': window.present() import pairing + from logitech.devices.constants import STATUS from gi.repository import Gtk, GObject listener = None notify_missing = True - def status_changed(receiver, device=None, urgent=False): + def status_changed(receiver, device=None, ui_flags=0): ui.update(receiver, icon, window, device) - if ui.notify.available and urgent: + if ui_flags & STATUS.UI_POPUP: + window.present() + + if ui_flags & STATUS.UI_NOTIFY and ui.notify.available: GObject.idle_add(ui.notify.show, device or receiver) global listener @@ -98,27 +109,34 @@ if __name__ == '__main__': from receiver import ReceiverListener def check_for_listener(retry=True): - global listener, notify_missing + def _check_still_scanning(listener): + if listener.receiver.status == STATUS.BOOTING: + listener.change_status(STATUS.CONNECTED) + global listener, notify_missing if listener is None: try: listener = ReceiverListener.open(status_changed) except OSError: - ui.show_permissions_warning(window) + ui.error(window, 'Permissions error', + 'Found a possible Unifying Receiver device,\n' + 'but did not have permission to open it.') if listener is None: pairing.state = None if notify_missing: - status_changed(DUMMY, None, True) + status_changed(DUMMY, None, STATUS.UI_NOTIFY) notify_missing = False return retry # print ("opened receiver", listener, listener.receiver) notify_missing = True pairing.state = pairing.State(listener) - status_changed(listener.receiver, None, True) + status_changed(listener.receiver, None, STATUS.UI_NOTIFY) + listener.trigger_device_events() + GObject.timeout_add(5 * 1000, _check_still_scanning, listener) - GObject.timeout_add(100, check_for_listener, False) + GObject.timeout_add(50, check_for_listener, False) Gtk.main() if listener is not None: diff --git a/app/ui/__init__.py b/app/ui/__init__.py index 0c5d7f02..2f0f05e6 100644 --- a/app/ui/__init__.py +++ b/app/ui/__init__.py @@ -6,8 +6,8 @@ from gi.repository import (GObject, Gtk) GObject.threads_init() -from solaar import NAME as _NAME -_APP_ICONS = (_NAME + '-fail', _NAME + '-init', _NAME) +from solaar import NAME +_APP_ICONS = (NAME + '-fail', NAME + '-init', NAME) def appicon(receiver_status): return (_APP_ICONS[0] if receiver_status < 0 else _APP_ICONS[1] if receiver_status < 1 else @@ -25,12 +25,9 @@ def icon_file(name): return None -def show_permissions_warning(window): - text = ('Found a possible Unifying Receiver device,\n' - 'but did not have permission to open it.') - +def error(window, title, text): m = Gtk.MessageDialog(window, Gtk.DialogFlags.MODAL, Gtk.MessageType.ERROR, Gtk.ButtonsType.CLOSE, text) - m.set_title('Permissions error') + m.set_title(title) m.run() m.destroy() diff --git a/app/ui/action.py b/app/ui/action.py index 8f5fb858..16a2bb36 100644 --- a/app/ui/action.py +++ b/app/ui/action.py @@ -3,10 +3,9 @@ # # from sys import version as PYTTHON_VERSION -from gi.repository import Gtk +from gi.repository import (Gtk, Gdk) -import ui.notify -import ui.pair_window +import ui from solaar import NAME as _NAME from solaar import VERSION as _VERSION @@ -45,7 +44,8 @@ def _show_about_window(action): about.set_logo_icon_name(_NAME) about.set_version(_VERSION) about.set_license_type(Gtk.License.GPL_2_0) - about.set_authors(('Daniel Pavel http://github.com/pwr', )) + about.set_authors(('Daniel Pavel http://github.com/pwr',)) + # about.add_credit_section('Testing', 'Douglas Wagner') about.set_website('http://github.com/pwr/Solaar/wiki') about.set_website_label('Solaar Wiki') # about.set_comments('Using Python %s\n' % PYTTHON_VERSION.split(' ')[0]) @@ -64,11 +64,12 @@ import pairing def _pair_device(action, frame): window = frame.get_toplevel() - pair_dialog = ui.pair_window.create( action, pairing.state) + pair_dialog = ui.pair_window.create(action, pairing.state) + # window.present() + pair_dialog.set_transient_for(window) pair_dialog.set_modal(True) - - window.present() + pair_dialog.set_type_hint(Gdk.WindowTypeHint.DIALOG) pair_dialog.present() def pair(frame): @@ -77,15 +78,19 @@ def pair(frame): def _unpair_device(action, frame): window = frame.get_toplevel() - window.present() + # window.present() device = frame._device qdialog = Gtk.MessageDialog(window, 0, - Gtk.MessageType.QUESTION, Gtk.ButtonsType.YES_NO, + Gtk.MessageType.QUESTION, Gtk.ButtonsType.NONE, "Unpair device\n%s ?" % device.name) + qdialog.set_icon_name('remove') + qdialog.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL) + qdialog.add_button('Unpair', Gtk.ResponseType.ACCEPT) choice = qdialog.run() qdialog.destroy() - if choice == Gtk.ResponseType.YES: - pairing.state.unpair(device) + if choice == Gtk.ResponseType.ACCEPT: + if not pairing.state.unpair(device): + ui.error(window, 'Unpairing failed', 'Failed to unpair device\n%s .' % device.name) def unpair(frame): return _action('remove', 'Unpair', _unpair_device, frame) diff --git a/app/ui/main_window.py b/app/ui/main_window.py index eb003386..794b3414 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -2,7 +2,7 @@ # # -from gi.repository import (Gtk, Gdk) +from gi.repository import (Gtk, Gdk, GObject) import ui from logitech.devices.constants import (STATUS, PROPS) @@ -17,22 +17,6 @@ _PLACEHOLDER = '~' # # -def _info_text(dev): - items = [('Serial', dev.serial)] + [(f.kind, ((f.name + ' ') if f.name else '') + f.version) for f in dev.firmware] - if hasattr(dev, 'number'): - items += [('HID', dev.protocol)] - - return '%s' % '\n'.join('%-10s: %s' % (item[0], str(item[1])) for item in items) - -def _toggle_info(action, label_widget, box_widget, frame): - if action.get_active(): - box_widget.set_visible(True) - if not label_widget.get_text(): - label_widget.set_markup(_info_text(frame._device)) - else: - box_widget.set_visible(False) - - def _make_receiver_box(name): frame = Gtk.Frame() frame._device = None @@ -66,11 +50,12 @@ def _make_receiver_box(name): info_box.add(info_label) info_box.set_shadow_type(Gtk.ShadowType.ETCHED_IN) - toggle_info_action = ui.action._toggle_action('info', 'Receiver info', _toggle_info, info_label, info_box, frame) + toggle_info_action = ui.action._toggle_action('info', 'Receiver info', _toggle_info_box, info_label, info_box, frame, _update_receiver_info_label) toolbar.insert(toggle_info_action.create_tool_item(), 0) toolbar.insert(ui.action.pair(frame).create_tool_item(), -1) + # toolbar.insert(ui.action.about.create_tool_item(), -1) - vbox = Gtk.VBox(homogeneous=False, spacing=2) + vbox = Gtk.VBox(homogeneous=False, spacing=4) vbox.set_border_width(4) vbox.pack_start(hbox, True, True, 0) vbox.pack_start(info_box, True, True, 0) @@ -127,11 +112,12 @@ def _make_device_box(index): info_label.set_alignment(0, 0.5) info_label.set_padding(8, 2) info_label.set_selectable(True) + info_label.fields = {} info_box = Gtk.Frame() info_box.add(info_label) - toggle_info_action = ui.action._toggle_action('info', 'Device info', _toggle_info, info_label, info_box, frame) + toggle_info_action = ui.action._toggle_action('info', 'Device info', _toggle_info_box, info_label, info_box, frame, _update_device_info_label) toolbar.insert(toggle_info_action.create_tool_item(), 0) toolbar.insert(ui.action.unpair(frame).create_tool_item(), -1) @@ -204,6 +190,57 @@ def create(title, name, max_devices, systray=False): # # +def _update_device_info_label(label, dev): + need_update = False + + if 'serial' in label.fields: + serial = label.fields['serial'] + else: + serial = label.fields['serial'] = dev.serial + need_update = True + + if 'firmware' in label.fields: + firmware = label.fields['firmware'] + else: + if dev.status >= STATUS.CONNECTED: + firmware = label.fields['firmware'] = dev.firmware + need_update = True + else: + firmware = None + + if 'hid' in label.fields: + hid = label.fields['hid'] + else: + if dev.status >= STATUS.CONNECTED: + hid = label.fields['hid'] = dev.protocol + need_update = True + else: + hid = None + + if need_update: + items = [('Serial', serial)] + if firmware: + items += [(f.kind, f.name + ' ' + f.version) for f in firmware] + if hid: + items += [('HID', hid)] + + label.set_markup('%s' % '\n'.join('%-10s: %s' % (item[0], str(item[1])) for item in items)) + + +def _update_receiver_info_label(label, dev): + if label.get_visible() and label.get_text() == '': + items = [('Serial', dev.serial)] + \ + [(f.kind, f.version) for f in dev.firmware] + label.set_markup('%s' % '\n'.join('%-10s: %s' % (item[0], str(item[1])) for item in items)) + +def _toggle_info_box(action, label_widget, box_widget, frame, update_function): + if action.get_active(): + box_widget.set_visible(True) + update_function(label_widget, frame._device) + else: + box_widget.set_visible(False) + + def _update_receiver_box(frame, receiver): label, toolbar, info_label = ui.find_children(frame, 'label', 'toolbar', 'info-label') @@ -220,44 +257,43 @@ def _update_receiver_box(frame, receiver): def _update_device_box(frame, dev): frame._device = dev + # print dev.name, dev.kind - icon, label = ui.find_children(frame, 'icon', 'label') + icon, label, info_label = ui.find_children(frame, 'icon', 'label', 'info-label') - if frame.get_name() != dev.name: + first_run = frame.get_name() != dev.name + if first_run: frame.set_name(dev.name) icon_name = ui.get_icon(dev.name, dev.kind) icon.set_from_icon_name(icon_name, _DEVICE_ICON_SIZE) label.set_markup('' + dev.name + '') - info_label = ui.find_children(frame, 'info-label') - info_label.set_text('') status = ui.find_children(frame, 'status') status_icons = status.get_children() - toolbar = status_icons[-1] - if dev.status < STATUS.CONNECTED: - icon.set_sensitive(False) - label.set_sensitive(False) - status.set_sensitive(False) - for c in status_icons[1:-1]: - c.set_visible(False) - toolbar.get_children()[0].set_active(False) - else: - icon.set_sensitive(True) - label.set_sensitive(True) - status.set_sensitive(True) + if dev.status < STATUS.CONNECTED: + battery_icon, battery_label = status_icons[0:2] + battery_icon.set_sensitive(False) + battery_label.set_markup('%s' % dev.status_text) + battery_label.set_sensitive(True) + for c in status_icons[2:-1]: + c.set_visible(False) + + else: battery_icon, battery_label = status_icons[0:2] battery_level = dev.props.get(PROPS.BATTERY_LEVEL) if battery_level is None: - battery_icon.set_from_icon_name('battery_unknown', _STATUS_ICON_SIZE) battery_icon.set_sensitive(False) - battery_label.set_visible(False) + battery_icon.set_from_icon_name('battery_unknown', _STATUS_ICON_SIZE) + text = 'no status' if dev.protocol < 2.0 else 'waiting for status...' + battery_label.set_markup('%s' % text) + battery_label.set_sensitive(False) else: icon_name = 'battery_%03d' % (20 * ((battery_level + 10) // 20)) battery_icon.set_from_icon_name(icon_name, _STATUS_ICON_SIZE) battery_icon.set_sensitive(True) battery_label.set_text('%d%%' % battery_level) - battery_label.set_visible(True) + battery_label.set_sensitive(True) battery_status = dev.props.get(PROPS.BATTERY_STATUS) battery_icon.set_tooltip_text(battery_status or '') @@ -274,10 +310,9 @@ def _update_device_box(frame, dev): light_label.set_text('%d lux' % light_level) light_label.set_visible(True) - for b in toolbar.get_children()[:-1]: - b.set_sensitive(True) - - frame.set_visible(True) + if first_run: + frame.set_visible(True) + GObject.timeout_add(2000, _update_device_info_label, info_label, dev) def update(window, receiver, device=None): diff --git a/app/ui/pair_window.py b/app/ui/pair_window.py index b56bb41a..03758755 100644 --- a/app/ui/pair_window.py +++ b/app/ui/pair_window.py @@ -8,14 +8,21 @@ from gi.repository import (Gtk, GObject) import ui -def _create_page(assistant, text, kind): +def _create_page(assistant, text, kind, icon_name=None): p = Gtk.VBox(False, 12) p.set_border_width(8) if text: + item = Gtk.HBox(homogeneous=False, spacing=16) + p.pack_start(item, False, True, 0) + label = Gtk.Label(text) label.set_alignment(0, 0) - p.pack_start(label, False, True, 0) + item.pack_start(label, True, True, 0) + + if icon_name: + icon = Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.DIALOG) + item.pack_start(icon, False, False, 0) assistant.append_page(p) assistant.set_page_type(p, kind) @@ -59,8 +66,9 @@ def _scan_complete_ui(assistant, device): page = _create_page(assistant, 'No new device detected.\n' '\n' - 'Make sure your device is within range of the receiver,\nand it has a decent battery charge.\n', - Gtk.AssistantPageType.CONFIRM) + 'Make sure your device is within the\nreceiver\'s range, and it has\na decent battery charge.\n', + Gtk.AssistantPageType.CONFIRM, + 'dialog-error') else: page = _create_page(assistant, None, @@ -110,7 +118,8 @@ def create(action, state): 'Turn on the device you want to pair.\n' '\n' 'If the device is already turned on,\nturn if off and on again.', - Gtk.AssistantPageType.INTRO) + Gtk.AssistantPageType.INTRO, + 'preferences-desktop-peripherals') spinner = Gtk.Spinner() spinner.set_visible(True) page_intro.pack_end(spinner, True, True, 16) diff --git a/app/ui/status_icon.py b/app/ui/status_icon.py index efee4426..869218b1 100644 --- a/app/ui/status_icon.py +++ b/app/ui/status_icon.py @@ -50,6 +50,6 @@ def update(icon, receiver): lines.append('') text = '\n'.join(lines).rstrip('\n') - icon.set_tooltip_markup(text) + icon.set_tooltip_markup(ui.NAME + ':\n' + text) else: - icon.set_tooltip_text(receiver.status_text) + icon.set_tooltip_text(ui.NAME + ': ' + receiver.status_text) diff --git a/lib/hidapi/__init__.py b/lib/hidapi/__init__.py index dc19ebfa..2b0e7cec 100644 --- a/lib/hidapi/__init__.py +++ b/lib/hidapi/__init__.py @@ -4,7 +4,4 @@ __author__ = "Daniel Pavel" __license__ = "GPL" __version__ = "0.4" -try: - from hidapi.udev import * -except ImportError: - from hidapi.native import * +from hidapi.udev import * diff --git a/lib/hidapi/hidconsole.py b/lib/hidapi/hidconsole.py index 92348c7a..e7787f6f 100644 --- a/lib/hidapi/hidconsole.py +++ b/lib/hidapi/hidconsole.py @@ -55,7 +55,8 @@ if __name__ == '__main__': print (".. Opening device %s" % args.device) handle = hidapi.open_path(args.device.encode('utf-8')) if handle: - print (".. Opened handle %X, vendor %s product %s serial %s" % (handle, + print (".. Opened handle %s, vendor %s product %s serial %s" % ( + repr(handle), repr(hidapi.get_manufacturer(handle)), repr(hidapi.get_product(handle)), repr(hidapi.get_serial(handle)))) @@ -101,7 +102,7 @@ if __name__ == '__main__': except Exception as e: print ('%s: %s' % (type(e).__name__, e)) - print (".. Closing handle %X" % handle) + print (".. Closing handle %s" % repr(handle)) hidapi.close(handle) if interactive: readline.write_history_file(args.history) diff --git a/lib/hidapi/native.py b/lib/hidapi/native.py index ce0b1773..baa23756 100644 --- a/lib/hidapi/native.py +++ b/lib/hidapi/native.py @@ -16,6 +16,10 @@ Currently the native libusb implementation (temporarily) detaches the device's USB driver from the kernel, and it may cause the device to become unresponsive. """ +# +# LEGACY, no longer supported +# + __version__ = '0.3-hidapi-0.7.0' diff --git a/lib/hidapi/udev.py b/lib/hidapi/udev.py index 8f1e7b6f..dc419db4 100644 --- a/lib/hidapi/udev.py +++ b/lib/hidapi/udev.py @@ -8,9 +8,10 @@ necessary. """ import os as _os +import errno as _errno from select import select as _select -from pyudev import Context as _Context -from pyudev import Device as _Device +from pyudev import (Context as _Context, + Device as _Device) native_implementation = 'udev' @@ -124,6 +125,7 @@ def open_path(device_path): :returns: an opaque device handle, or ``None``. """ + assert '/dev/hidraw' in device_path return _os.open(device_path, _os.O_RDWR | _os.O_SYNC) @@ -155,14 +157,11 @@ def write(device_handle, data): write() will send the data on the first OUT endpoint, if one exists. If it does not, it will send the data through the Control Endpoint (Endpoint 0). - - :returns: ``True`` if the write was successful. """ - try: - bytes_written = _os.write(device_handle, data) - return bytes_written == len(data) - except: - pass + bytes_written = _os.write(device_handle, data) + + if bytes_written != len(data): + raise OSError(errno=_errno.EIO, strerror='written %d bytes out of expected %d' % (bytes_written, len(data))) def read(device_handle, bytes_count, timeout_ms=-1): @@ -181,15 +180,19 @@ def read(device_handle, bytes_count, timeout_ms=-1): :returns: the data packet read, an empty bytes string if a timeout was reached, or None if there was an error while reading. """ - try: - timeout = None if timeout_ms < 0 else timeout_ms / 1000.0 - rlist, wlist, xlist = _select([device_handle], [], [], timeout) - if rlist: - assert rlist == [device_handle] - return _os.read(device_handle, bytes_count) + timeout = None if timeout_ms < 0 else timeout_ms / 1000.0 + rlist, wlist, xlist = _select([device_handle], [], [device_handle], timeout) + + if xlist: + assert xlist == [device_handle] + raise OSError(errno=_errno.EIO, strerror='exception on file descriptor %d' % device_handle) + + if rlist: + assert rlist == [device_handle] + data = _os.read(device_handle, bytes_count) + return data + else: return b'' - except OSError: - pass _DEVICE_STRINGS = { diff --git a/lib/logitech/devices/__init__.py b/lib/logitech/devices/__init__.py index c415a415..5cc7d0ce 100644 --- a/lib/logitech/devices/__init__.py +++ b/lib/logitech/devices/__init__.py @@ -5,7 +5,7 @@ import logging from .constants import (STATUS, PROPS) -from ..unifying_receiver.constants import (FEATURE, BATTERY_STATUS) +from ..unifying_receiver.constants import (FEATURE, BATTERY_STATUS, BATTERY_OK) from ..unifying_receiver import api as _api # @@ -14,17 +14,17 @@ from ..unifying_receiver import api as _api _DEVICE_MODULES = {} -def _module(device_name): - if device_name not in _DEVICE_MODULES: - shortname = device_name.split(' ')[-1].lower() +def _module(device): + shortname = device.codename.lower().replace(' ', '_') + if shortname not in _DEVICE_MODULES: try: m = __import__(shortname, globals(), level=1) - _DEVICE_MODULES[device_name] = m + _DEVICE_MODULES[shortname] = m except: # logging.exception(shortname) - _DEVICE_MODULES[device_name] = None + _DEVICE_MODULES[shortname] = None - return _DEVICE_MODULES[device_name] + return _DEVICE_MODULES[shortname] # # @@ -34,8 +34,11 @@ def default_request_status(devinfo): if FEATURE.BATTERY in devinfo.features: reply = _api.get_device_battery_level(devinfo.handle, devinfo.number, features=devinfo.features) if reply: - discharge, dischargeNext, status = reply - return STATUS.CONNECTED, {PROPS.BATTERY_LEVEL: discharge, PROPS.BATTERY_STATUS: status} + b_discharge, dischargeNext, b_status = reply + return STATUS.CONNECTED, { + PROPS.BATTERY_LEVEL: b_discharge, + PROPS.BATTERY_STATUS: b_status, + } reply = _api.ping(devinfo.handle, devinfo.number) return STATUS.CONNECTED if reply else STATUS.UNAVAILABLE @@ -44,29 +47,33 @@ def default_request_status(devinfo): def default_process_event(devinfo, data): feature_index = ord(data[0:1]) if feature_index >= len(devinfo.features): - logging.warn("mistery event %s for %s", repr(data), devinfo) + # logging.warn("mistery event %s for %s", repr(data), devinfo) return None feature = devinfo.features[feature_index] feature_function = ord(data[1:2]) & 0xF0 if feature == FEATURE.BATTERY: - if feature_function == 0: - discharge = ord(data[2:3]) - status = BATTERY_STATUS[ord(data[3:4])] - return STATUS.CONNECTED, {PROPS.BATTERY_LEVEL: discharge, PROPS.BATTERY_STATUS: status} + if feature_function == 0x00: + b_discharge = ord(data[2:3]) + b_status = ord(data[3:4]) + return STATUS.CONNECTED, { + PROPS.BATTERY_LEVEL: b_discharge, + PROPS.BATTERY_STATUS: BATTERY_STATUS[b_status], + PROPS.UI_FLAGS: 0 if BATTERY_OK(b_status) else STATUS.UI_NOTIFY, + } # ? elif feature == FEATURE.REPROGRAMMABLE_KEYS: - if feature_function == 0: + if feature_function == 0x00: logging.debug('reprogrammable key: %s', repr(data)) # TODO pass # ? elif feature == FEATURE.WIRELESS: - if feature_function == 0: + if feature_function == 0x00: logging.debug("wireless status: %s", repr(data)) if data[2:5] == b'\x01\x01\x01': - return STATUS.CONNECTED + return STATUS.CONNECTED, {PROPS.UI_FLAGS: STATUS.UI_NOTIFY} # TODO pass # ? @@ -79,7 +86,7 @@ def request_status(devinfo): :param listener: the EventsListener that will be used to send the request, and which will receive the status events from the device. """ - m = _module(devinfo.name) + m = _module(devinfo) if m and 'request_status' in m.__dict__: return m.request_status(devinfo) return default_request_status(devinfo) @@ -95,6 +102,6 @@ def process_event(devinfo, data): if default_result is not None: return default_result - m = _module(devinfo.name) + m = _module(devinfo) if m and 'process_event' in m.__dict__: return m.process_event(devinfo, data) diff --git a/lib/logitech/devices/constants.py b/lib/logitech/devices/constants.py index 181be55e..0333b735 100644 --- a/lib/logitech/devices/constants.py +++ b/lib/logitech/devices/constants.py @@ -4,8 +4,10 @@ STATUS = type('STATUS', (), dict( - UNKNOWN=-9999, - UNPAIRED=-1000, + UI_NOTIFY=0x01, + UI_POPUP=0x02, + UNKNOWN=-0xFFFF, + UNPAIRED=-0x1000, UNAVAILABLE=-1, BOOTING=0, CONNECTED=1, @@ -26,6 +28,7 @@ PROPS = type('PROPS', (), BATTERY_LEVEL='battery_level', BATTERY_STATUS='battery_status', LIGHT_LEVEL='light_level', + UI_FLAGS='ui_flags', )) # when the receiver reports a device that is not connected diff --git a/lib/logitech/devices/k750.py b/lib/logitech/devices/k750.py index 54db5f60..fdd3dbc4 100644 --- a/lib/logitech/devices/k750.py +++ b/lib/logitech/devices/k750.py @@ -47,4 +47,9 @@ def process_event(devinfo, data): if data[:2] == b'\x09\x20' and data[7:11] == b'GOOD': logging.debug("Solar key pressed") - return request_status(devinfo) or _charge_status(data) + if request_status(devinfo) == STATUS.UNAVAILABLE: + return STATUS.UNAVAILABLE, {PROPS.UI_FLAGS: STATUS.UI_POPUP | STATUS.UI_NOTIFY} + + code, props = _charge_status(data) + props[PROPS.UI_FLAGS] = STATUS.UI_POPUP + return code, props diff --git a/lib/logitech/scanner.py b/lib/logitech/scanner.py index 071bff5e..6c9a8cd7 100644 --- a/lib/logitech/scanner.py +++ b/lib/logitech/scanner.py @@ -7,17 +7,26 @@ def print_receiver(receiver): print (" Serial : %s" % receiver.serial) for f in receiver.firmware: print (" %-10s: %s" % (f.kind, f.version)) + print (" Receiver reported %d paired device(s)" % len(receiver)) def scan_devices(receiver): - for dev in receiver: + for number in range(1, 1 + receiver.max_devices): + dev = receiver[number] + if dev is None: + dev = api.PairedDevice(receiver.handle, number) + if dev.codename is None: + continue + print ("--------") print (str(dev)) + print ("Codename : %s" % dev.codename) print ("Name : %s" % dev.name) print ("Kind : %s" % dev.kind) print ("Serial number: %s" % dev.serial) + if not dev.protocol: - print ("HID protocol : UNKNOWN") + print ("Device is not connected at this time, no further info available.") continue print ("HID protocol : HID %01.1f" % dev.protocol) @@ -27,7 +36,7 @@ def scan_devices(receiver): firmware = dev.firmware for fw in firmware: - print (" %-10s: %s %s" % (fw.kind, fw.name, fw.version)) + print (" %-11s: %s %s" % (fw.kind, fw.name, fw.version)) all_features = api.get_device_features(dev.handle, dev.number) for index in range(0, len(all_features)): @@ -45,9 +54,7 @@ def scan_devices(receiver): print (" %d reprogrammable keys found" % len(keys)) for k in keys: flags = ','.join(KEY_FLAG_NAME[f] for f in KEY_FLAG_NAME if k.flags & f) - print (" %2d: %-12s => %-12s :%s" % (k.index, KEY_NAME[k.id], KEY_NAME[k.task], flags)) - - print ("--------") + print (" %2d: %-12s => %-12s : %s" % (k.index, KEY_NAME[k.id], KEY_NAME[k.task], flags)) if __name__ == '__main__': @@ -65,9 +72,8 @@ if __name__ == '__main__': receiver = api.Receiver.open() if receiver is None: - print ("!! Logitech Unifying Receiver not found.") + print ("Logitech Unifying Receiver not found.") else: - print ("!! Found Logitech Unifying Receiver: %s" % receiver) print_receiver(receiver) scan_devices(receiver) receiver.close() diff --git a/lib/logitech/unifying_receiver/api.py b/lib/logitech/unifying_receiver/api.py index 648cf69a..2b2f97f6 100644 --- a/lib/logitech/unifying_receiver/api.py +++ b/lib/logitech/unifying_receiver/api.py @@ -5,6 +5,7 @@ from struct import pack as _pack from struct import unpack as _unpack import errno as _errno +from threading import local as _local from . import base as _base @@ -27,6 +28,56 @@ del getLogger # # +class ThreadedHandle(object): + __slots__ = ['path', '_local', '_handles'] + + def __init__(self, initial_handle, path): + if type(initial_handle) != int: + raise TypeError('expected int as initial handle, got %s' % repr(initial_handle)) + + self.path = path + self._local = _local() + self._local.handle = initial_handle + self._handles = [initial_handle] + + def _open(self): + handle = _base.open_path(self.path) + if handle is None: + _log.error("%s failed to open new handle", repr(self)) + else: + # _log.debug("%s opened new handle %d", repr(self), handle) + self._local.handle = handle + self._handles.append(handle) + return handle + + def close(self): + self._local = None + handles, self._handles = self._handles, [] + _log.debug("%s closing %s", repr(self), handles) + for h in handles: + _base.close(h) + + def __del__(self): + self.close() + + def __int__(self): + if self._local: + try: + return self._local.handle + except: + return self._open() + + def __str__(self): + return str(int(self)) + + def __repr__(self): + return '' % self.path + + def __bool__(self): + return bool(self._handles) + __nonzero__ = __bool__ + + class PairedDevice(object): def __init__(self, handle, number): self.handle = handle @@ -40,11 +91,15 @@ class PairedDevice(object): self._serial = None self._firmware = None + def __del__(self): + self.handle = None + @property def protocol(self): if self._protocol is None: self._protocol = _base.ping(self.handle, self.number) - return 0 if self._protocol is None else self._protocol + # _log.debug("device %d protocol %s", self.number, self._protocol) + return self._protocol or 0 @property def features(self): @@ -59,7 +114,8 @@ class PairedDevice(object): codename = _base.request(self.handle, 0xFF, b'\x83\xB5', 0x40 + self.number - 1) if codename: self._codename = codename[2:].rstrip(b'\x00').decode('ascii') - return self._codename or '?' + # _log.debug("device %d codename %s", self.number, self._codename) + return self._codename @property def name(self): @@ -70,7 +126,7 @@ class PairedDevice(object): self._name, self._kind = _DEVICE_NAMES[self._codename] else: self._name = get_device_name(self.handle, self.number, self.features) - return self._name or self.codename + return self._name or self.codename or '?' @property def kind(self): @@ -87,6 +143,7 @@ class PairedDevice(object): def firmware(self): if self._firmware is None and self.protocol >= 2.0: self._firmware = get_device_firmware(self.handle, self.number, self.features) + # _log.debug("device %d firmware %s", self.number, self._firmware) return self._firmware or () @property @@ -96,16 +153,14 @@ class PairedDevice(object): serial = _base.request(self.handle, 0xFF, b'\x83\xB5', 0x30 + self.number - 1) if prefix and serial: self._serial = _base._hex(prefix[3:5]) + '-' + _base._hex(serial[1:5]) + # _log.debug("device %d serial %s", self.number, self._serial) return self._serial or '?' def ping(self): return _base.ping(self.handle, self.number) is not None def __str__(self): - return '' % (self.handle, self.number, self._name or '?') - - def __hash__(self): - return self.number + return '' % (self.handle, self.number, self.codename or '?') class Receiver(object): @@ -120,9 +175,12 @@ class Receiver(object): self._firmware = None def close(self): - handle, self.handle = self.handle, 0 + handle, self.handle = self.handle, None return (handle and _base.close(handle)) + def __del__(self): + self.close() + @property def serial(self): if self._serial is None and self.handle: @@ -153,7 +211,7 @@ class Receiver(object): return self._firmware def __iter__(self): - if self.handle == 0: + if not self.handle: return for number in range(1, 1 + MAX_ATTACHED_DEVICES): @@ -164,14 +222,14 @@ class Receiver(object): def __getitem__(self, key): if type(key) != int: raise TypeError('key must be an integer') - if self.handle == 0 or key < 0 or key > MAX_ATTACHED_DEVICES: + if not self.handle or key < 0 or key > MAX_ATTACHED_DEVICES: raise IndexError(key) return get_device(self.handle, key) if key > 0 else None def __delitem__(self, key): if type(key) != int: raise TypeError('key must be an integer') - if self.handle == 0 or key < 0 or key > MAX_ATTACHED_DEVICES: + if not self.handle or key < 0 or key > MAX_ATTACHED_DEVICES: raise IndexError(key) if key > 0: _log.debug("unpairing device %d", key) @@ -180,13 +238,14 @@ class Receiver(object): raise IndexError(key) def __len__(self): - if self.handle == 0: + if not self.handle: return 0 # not really sure about this one... count = _base.request(self.handle, 0xFF, b'\x81\x00') return 0 if count is None else ord(count[1:2]) def __contains__(self, dev): + # print self, "contains", dev if self.handle == 0: return False if type(dev) == int: @@ -194,10 +253,7 @@ class Receiver(object): return dev.ping() def __str__(self): - return '' % (self.handle, self.path) - - def __hash__(self): - return self.handle + return '' % (self.handle, self.path) __bool__ = __nonzero__ = lambda self: self.handle != 0 @@ -212,7 +268,7 @@ class Receiver(object): for rawdevice in _base.list_receiver_devices(): exception = None try: - handle = _base.try_open(rawdevice.path) + handle = _base.open_path(rawdevice.path) if handle: return Receiver(handle, rawdevice.path) except OSError as e: @@ -295,13 +351,13 @@ def get_feature_index(handle, devnumber, feature): if reply: feature_index = ord(reply[0:1]) if feature_index: - # feature_flags = ord(reply[1:2]) & 0xE0 - # if feature_flags: - # _log.debug("device %d feature <%s:%s> has index %d: %s", - # devnumber, _hex(feature), FEATURE_NAME[feature], feature_index, - # ','.join([FEATURE_FLAGS[k] for k in FEATURE_FLAGS if feature_flags & k])) - # else: - # _log.debug("device %d feature <%s:%s> has index %d", devnumber, _hex(feature), FEATURE_NAME[feature], feature_index) + feature_flags = ord(reply[1:2]) & 0xE0 + if feature_flags: + _log.debug("device %d feature <%s:%s> has index %d: %s", + devnumber, _hex(feature), FEATURE_NAME[feature], feature_index, + ','.join([FEATURE_FLAGS[k] for k in FEATURE_FLAGS if feature_flags & k])) + else: + _log.debug("device %d feature <%s:%s> has index %d", devnumber, _hex(feature), FEATURE_NAME[feature], feature_index) # only consider active and supported features? # if feature_flags: @@ -322,9 +378,12 @@ def _get_feature_index(handle, devnumber, feature, features=None): index = get_feature_index(handle, devnumber, feature) if index is not None: - if len(features) <= index: - features += [None] * (index + 1 - len(features)) - features[index] = feature + try: + if len(features) <= index: + features += [None] * (index + 1 - len(features)) + features[index] = feature + except: + pass # _log.debug("%s: found feature %s at %d", features, _base._hex(feature), index) return index diff --git a/lib/logitech/unifying_receiver/base.py b/lib/logitech/unifying_receiver/base.py index ce54484d..ef5794df 100644 --- a/lib/logitech/unifying_receiver/base.py +++ b/lib/logitech/unifying_receiver/base.py @@ -3,6 +3,8 @@ # Unlikely to be used directly unless you're expanding the API. # +import os as _os +from time import time as _timestamp from struct import pack as _pack from binascii import hexlify as _hexlify _hex = lambda d: _hexlify(d).decode('ascii').upper() @@ -40,32 +42,7 @@ _MAX_REPLY_SIZE = _MAX_CALL_SIZE """Default timeout on read (in ms).""" -DEFAULT_TIMEOUT = 1500 - -# -# -# - -def _logdebug_hook(reply_code, devnumber, data): - """Default unhandled hook, logs the reply as DEBUG.""" - _log.warn("UNHANDLED [%02X %02X %s %s] (%s)", reply_code, devnumber, _hex(data[:2]), _hex(data[2:]), repr(data)) - - -"""The function that will be called on unhandled incoming events. - -The hook must be a function with the signature: ``_(int, int, str)``, where -the parameters are: (reply_code, devnumber, data). - -This hook will only be called by the request() function, when it receives -replies that do not match the requested feature call. As such, it is not -suitable for intercepting broadcast events from the device (e.g. special -keys being pressed, battery charge events, etc), at least not in a timely -manner. However, these events *may* be delivered here if they happen while -doing a feature call to the device. - -The default implementation logs the unhandled reply as DEBUG. -""" -unhandled_hook = _logdebug_hook +DEFAULT_TIMEOUT = 2000 # # @@ -76,13 +53,10 @@ def list_receiver_devices(): # (Vendor ID, Product ID) = ('Logitech', 'Unifying Receiver') # interface 2 if the actual receiver interface for d in _hid.enumerate(0x046d, 0xc52b, 2): - if d.driver is None or d.driver == 'logitech-djreceiver': + if d.driver == 'logitech-djreceiver': yield d - -_COUNT_DEVICES_REQUEST = b'\x10\xFF\x81\x00\x00\x00\x00' - -def try_open(path): +def open_path(path): """Checks if the given Linux device path points to the right UR device. :param path: the Linux device path. @@ -95,28 +69,7 @@ def try_open(path): :returns: an open receiver handle if this is the right Linux device, or ``None``. """ - receiver_handle = _hid.open_path(path) - if receiver_handle is None: - # could be a file permissions issue (did you add the udev rules?) - # in any case, unreachable - _log.debug("[%s] open failed", path) - return None - - _hid.write(receiver_handle, _COUNT_DEVICES_REQUEST) - - # if this is the right hidraw device, we'll receive a 'bad device' from the UR - # otherwise, the read should produce nothing - reply = _hid.read(receiver_handle, _MAX_REPLY_SIZE, DEFAULT_TIMEOUT / 2) - if reply: - if reply[:5] == _COUNT_DEVICES_REQUEST[:5]: - # 'device 0 unreachable' is the expected reply from a valid receiver handle - _log.info("[%s] success: handle %X", path, receiver_handle) - return receiver_handle - _log.debug("[%s] %X ignored reply %s", path, receiver_handle, _hex(reply)) - else: - _log.debug("[%s] %X no reply", path, receiver_handle) - - close(receiver_handle) + return _hid.open_path(path) def open(): @@ -125,8 +78,7 @@ def open(): :returns: An open file handle for the found receiver, or ``None``. """ for rawdevice in list_receiver_devices(): - _log.info("checking %s", rawdevice) - handle = try_open(rawdevice.path) + handle = open_path(rawdevice.path) if handle: return handle @@ -135,11 +87,15 @@ def close(handle): """Closes a HID device handle.""" if handle: try: - _hid.close(handle) - # _log.info("closed receiver handle %X", handle) + if type(handle) == int: + _hid.close(handle) + else: + handle.close() + # _log.info("closed receiver handle %s", repr(handle)) return True except: - _log.exception("closing receiver handle %X", handle) + # _log.exception("closing receiver handle %s", repr(handle)) + pass return False @@ -162,11 +118,14 @@ def write(handle, devnumber, data): assert _MAX_CALL_SIZE == 20 # the data is padded to either 5 or 18 bytes wdata = _pack('!BB18s' if len(data) > 5 else '!BB5s', 0x10, devnumber, data) - _log.debug("<= w[10 %02X %s %s]", devnumber, _hex(wdata[2:4]), _hex(wdata[4:])) - if not _hid.write(handle, wdata): - _log.warn("write failed, assuming receiver %X no longer available", handle) + _log.debug("(%s) <= w[10 %02X %s %s]", handle, devnumber, _hex(wdata[2:4]), _hex(wdata[4:])) + + try: + _hid.write(int(handle), wdata) + except Exception as reason: + _log.error("write failed, assuming handle %s no longer available", repr(handle)) close(handle) - raise _NoReceiver + raise _NoReceiver(reason) def read(handle, timeout=DEFAULT_TIMEOUT): @@ -185,32 +144,61 @@ def read(handle, timeout=DEFAULT_TIMEOUT): been physically removed from the machine, or the kernel driver has been unloaded. The handle will be closed automatically. """ - data = _hid.read(handle, _MAX_REPLY_SIZE, timeout) - if data is None: - _log.warn("read failed, assuming receiver %X no longer available", handle) + try: + data = _hid.read(int(handle), _MAX_REPLY_SIZE, timeout) + except Exception as reason: + _log.error("read failed, assuming handle %s no longer available", repr(handle)) close(handle) - raise _NoReceiver + raise _NoReceiver(reason) if data: if len(data) < _MIN_REPLY_SIZE: - _log.warn("=> r[%s] read packet too short: %d bytes", _hex(data), len(data)) + _log.warn("(%s) => r[%s] read packet too short: %d bytes", handle, _hex(data), len(data)) data += b'\x00' * (_MIN_REPLY_SIZE - len(data)) if len(data) > _MAX_REPLY_SIZE: - _log.warn("=> r[%s] read packet too long: %d bytes", _hex(data), len(data)) + _log.warn("(%s) => r[%s] read packet too long: %d bytes", handle, _hex(data), len(data)) code = ord(data[:1]) devnumber = ord(data[1:2]) - _log.debug("=> r[%02X %02X %s %s]", code, devnumber, _hex(data[2:4]), _hex(data[4:])) + _log.debug("(%s) => r[%02X %02X %s %s]", handle, code, devnumber, _hex(data[2:4]), _hex(data[4:])) return code, devnumber, data[2:] # _l.log(_LOG_LEVEL, "(-) => r[]") +def _skip_incoming(handle): + ihandle = int(handle) + + while True: + try: + data = _hid.read(ihandle, _MAX_REPLY_SIZE, 0) + except Exception as reason: + _log.error("read failed, assuming receiver %s no longer available", handle) + close(handle) + raise _NoReceiver(reason) + + if data: + if unhandled_hook: + unhandled_hook(ord(data[:1]), ord(data[1:2]), data[2:]) + else: + return + +# +# +# + +"""The function that will be called on unhandled incoming events. + +The hook must be a function with the signature: ``_(int, int, str)``, where +the parameters are: (reply_code, devnumber, data). + +This hook will only be called by the request() function, when it receives +replies that do not match the requested feature call. As such, it is not +suitable for intercepting broadcast events from the device (e.g. special +keys being pressed, battery charge events, etc), at least not in a timely +manner. However, these events *may* be delivered here if they happen while +doing a feature call to the device. +""" +unhandled_hook = None -_MAX_READ_TIMES = 3 -request_context = None -from collections import namedtuple -_DEFAULT_REQUEST_CONTEXT_CLASS = namedtuple('_DEFAULT_REQUEST_CONTEXT_CLASS', ['write', 'read', 'unhandled_hook']) -_DEFAULT_REQUEST_CONTEXT = _DEFAULT_REQUEST_CONTEXT_CLASS(write=write, read=read, unhandled_hook=unhandled_hook) -del namedtuple def request(handle, devnumber, feature_index_function, params=b'', features=None): """Makes a feature call to a device and waits for a matching reply. @@ -232,118 +220,93 @@ def request(handle, devnumber, feature_index_function, params=b'', features=None if type(params) == int: params = _pack('!B', params) - # _log.debug("device %d request {%s} params [%s]", devnumber, _hex(feature_index_function), _hex(params)) + # _log.debug("%s device %d request {%s} params [%s]", handle, devnumber, _hex(feature_index_function), _hex(params)) if len(feature_index_function) != 2: raise ValueError('invalid feature_index_function {%s}: it must be a two-byte string' % _hex(feature_index_function)) - if request_context is None or handle != request_context.handle: - context = _DEFAULT_REQUEST_CONTEXT - _unhandled = unhandled_hook - else: - context = request_context - _unhandled = getattr(context, 'unhandled_hook') + _skip_incoming(handle) + ihandle = int(handle) + write(ihandle, devnumber, feature_index_function + params) - context.write(handle, devnumber, feature_index_function + params) + while True: + now = _timestamp() + reply = read(ihandle, DEFAULT_TIMEOUT) + delta = _timestamp() - now - read_times = _MAX_READ_TIMES - while read_times > 0: - divisor = (1 + _MAX_READ_TIMES - read_times) - reply = context.read(handle, int(DEFAULT_TIMEOUT * (divisor + 1) / 2 / divisor)) - read_times -= 1 + if reply: + reply_code, reply_devnumber, reply_data = reply + if reply_devnumber == devnumber: + if reply_code == 0x10 and reply_data[:1] == b'\x8F' and reply_data[1:3] == feature_index_function: + # device not present + _log.debug("device %d request failed on {%s} call: [%s]", devnumber, _hex(feature_index_function), _hex(reply_data)) + return None - if not reply: - # keep waiting... - continue + if reply_code == 0x10 and reply_data[:1] == b'\x8F': + # device not present + _log.debug("device %d request failed: [%s]", devnumber, _hex(reply_data)) + return None - reply_code, reply_devnumber, reply_data = reply + if reply_code == 0x11 and reply_data[0] == b'\xFF' and reply_data[1:3] == feature_index_function: + # the feature call returned with an error + error_code = ord(reply_data[3]) + _log.warn("device %d request feature call error %d = %s: %s", devnumber, error_code, ERROR_NAME[error_code], _hex(reply_data)) + feature_index = ord(feature_index_function[:1]) + feature_function = feature_index_function[1:2] + feature = None if features is None else features[feature_index] if feature_index < len(features) else None + raise _FeatureCallError(devnumber, feature, feature_index, feature_function, error_code, reply_data) - if reply_devnumber != devnumber: - # this message not for the device we're interested in - # _l.log(_LOG_LEVEL, "device %d request got reply for unexpected device %d: [%s]", devnumber, reply_devnumber, _hex(reply_data)) - # worst case scenario, this is a reply for a concurrent request - # on this receiver - if _unhandled: - _unhandled(reply_code, reply_devnumber, reply_data) - continue + if reply_code == 0x11 and reply_data[:2] == feature_index_function: + # a matching reply + # _log.debug("device %d matched reply with feature-index-function [%s]", devnumber, _hex(reply_data[2:])) + return reply_data[2:] - if reply_code == 0x10 and reply_data[:1] == b'\x8F' and reply_data[1:3] == feature_index_function: - # device not present - _log.debug("device %d request failed on {%s} call: [%s]", devnumber, _hex(feature_index_function), _hex(reply_data)) + if reply_code == 0x10 and devnumber == 0xFF and reply_data[:2] == feature_index_function: + # direct calls to the receiver (device 0xFF) may also return successfully with reply code 0x10 + # _log.debug("device %d matched reply with feature-index-function [%s]", devnumber, _hex(reply_data[2:])) + return reply_data[2:] + + if unhandled_hook: + unhandled_hook(reply_code, reply_devnumber, reply_data) + + if delta >= DEFAULT_TIMEOUT: + _log.warn("timeout on device %d request {%s} params[%s]", devnumber, _hex(feature_index_function), _hex(params)) return None - if reply_code == 0x10 and reply_data[:1] == b'\x8F': - # device not present - _log.debug("device %d request failed: [%s]", devnumber, _hex(reply_data)) - return None - - if reply_code == 0x11 and reply_data[0] == b'\xFF' and reply_data[1:3] == feature_index_function: - # the feature call returned with an error - error_code = ord(reply_data[3]) - _log.warn("device %d request feature call error %d = %s: %s", devnumber, error_code, ERROR_NAME[error_code], _hex(reply_data)) - feature_index = ord(feature_index_function[:1]) - feature_function = feature_index_function[1:2] - feature = None if features is None else features[feature_index] if feature_index < len(features) else None - raise _FeatureCallError(devnumber, feature, feature_index, feature_function, error_code, reply_data) - - if reply_code == 0x11 and reply_data[:2] == feature_index_function: - # a matching reply - # _log.debug("device %d matched reply with feature-index-function [%s]", devnumber, _hex(reply_data[2:])) - return reply_data[2:] - - if reply_code == 0x10 and devnumber == 0xFF and reply_data[:2] == feature_index_function: - # direct calls to the receiver (device 0xFF) may also return successfully with reply code 0x10 - # _log.debug("device %d matched reply with feature-index-function [%s]", devnumber, _hex(reply_data[2:])) - return reply_data[2:] - - # _log.debug("device %d unmatched reply {%s} (expected {%s})", devnumber, _hex(reply_data[:2]), _hex(feature_index_function)) - if _unhandled: - _unhandled(reply_code, reply_devnumber, reply_data) - def ping(handle, devnumber): """Check if a device is connected to the UR. :returns: The HID protocol supported by the device, as a floating point number, if the device is active. """ - if request_context is None or handle != request_context.handle: - context = _DEFAULT_REQUEST_CONTEXT - _unhandled = unhandled_hook - else: - context = request_context - _unhandled = getattr(context, 'unhandled_hook') + _log.debug("%s pinging device %d", handle, devnumber) - context.write(handle, devnumber, b'\x00\x11\x00\x00\xAA') - read_times = _MAX_READ_TIMES - while read_times > 0: - divisor = (1 + _MAX_READ_TIMES - read_times) - reply = context.read(handle, int(DEFAULT_TIMEOUT * (divisor + 1) / 2 / divisor)) - read_times -= 1 + _skip_incoming(handle) + ihandle = int(handle) + write(ihandle, devnumber, b'\x00\x11\x00\x00\xAA') - if not reply: - # keep waiting... - continue + while True: + now = _timestamp() + reply = read(ihandle, DEFAULT_TIMEOUT) + delta = _timestamp() - now - reply_code, reply_devnumber, reply_data = reply + if reply: + reply_code, reply_devnumber, reply_data = reply + if reply_devnumber == devnumber: + if reply_code == 0x11 and reply_data[:2] == b'\x00\x11' and reply_data[4:5] == b'\xAA': + # HID 2.0+ device, currently connected + return ord(reply_data[2:3]) + ord(reply_data[3:4]) / 10.0 - if reply_devnumber != devnumber: - # this message not for the device we're interested in - # _l.log(_LOG_LEVEL, "device %d request got reply for unexpected device %d: [%s]", devnumber, reply_devnumber, _hex(reply_data)) - # worst case scenario, this is a reply for a concurrent request - # on this receiver - if _unhandled: - _unhandled(reply_code, reply_devnumber, reply_data) - continue + if reply_code == 0x10 and reply_data == b'\x8F\x00\x11\x01\x00': + # HID 1.0 device, currently connected + return 1.0 - if reply_code == 0x11 and reply_data[:2] == b'\x00\x11' and reply_data[4:5] == b'\xAA': - # HID 2.0+ device, currently connected - return ord(reply_data[2:3]) + ord(reply_data[3:4]) / 10.0 + if reply_code == 0x10 and reply_data[:3] == b'\x8F\x00\x11': + # a disconnected device + return None - if reply_code == 0x10 and reply_data == b'\x8F\x00\x11\x01\x00': - # HID 1.0 device, currently connected - return 1.0 + if unhandled_hook: + unhandled_hook(reply_code, reply_devnumber, reply_data) - if reply_code == 0x10 and reply_data[:3] == b'\x8F\x00\x11': - # a disconnected device + if delta >= DEFAULT_TIMEOUT: + _log.warn("timeout on device %d ping", devnumber) return None - - _log.warn("don't know how to interpret ping reply %s", reply) diff --git a/lib/logitech/unifying_receiver/constants.py b/lib/logitech/unifying_receiver/constants.py index dbdbaa22..83803365 100644 --- a/lib/logitech/unifying_receiver/constants.py +++ b/lib/logitech/unifying_receiver/constants.py @@ -65,6 +65,7 @@ FIRMWARE_KIND = FallbackDict(lambda x: 'Unknown', list2dict(_FIRMWARE_KINDS)) _BATTERY_STATUSES = ('Discharging (in use)', 'Recharging', 'Almost full', 'Full', 'Slow recharge', 'Invalid battery', 'Thermal error', 'Charging error') +BATTERY_OK = lambda status: status < 5 """Names for possible battery status values.""" BATTERY_STATUS = FallbackDict(lambda x: 'unknown', list2dict(_BATTERY_STATUSES)) diff --git a/lib/logitech/unifying_receiver/listener.py b/lib/logitech/unifying_receiver/listener.py index db32ba44..21d16330 100644 --- a/lib/logitech/unifying_receiver/listener.py +++ b/lib/logitech/unifying_receiver/listener.py @@ -2,12 +2,12 @@ # # -from threading import Thread as _Thread -# from time import sleep as _sleep +import threading as _threading from . import base as _base from .exceptions import NoReceiver as _NoReceiver from .common import Packet as _Packet +from .constants import MAX_ATTACHED_DEVICES as _MAX_ATTACHED_DEVICES # for both Python 2 and 3 try: @@ -21,26 +21,10 @@ _log = getLogger('LUR').getChild('listener') del getLogger -_READ_EVENT_TIMEOUT = int(_base.DEFAULT_TIMEOUT / 2) # ms - -def _event_dispatch(listener, callback): - while listener._active: # or not listener._events.empty(): - try: - event = listener._events.get(True, _READ_EVENT_TIMEOUT * 10) - except: - continue - # _log.debug("delivering event %s", event) - try: - callback(event) - except: - _log.exception("callback for %s", event) - - -class EventsListener(_Thread): +class EventsListener(_threading.Thread): """Listener thread for events from the Unifying Receiver. - Incoming packets will be passed to the callback function in sequence, by a - separate thread. + Incoming packets will be passed to the callback function in sequence. """ def __init__(self, receiver_handle, events_callback): super(EventsListener, self).__init__(group='Unifying Receiver', name=self.__class__.__name__) @@ -49,92 +33,65 @@ class EventsListener(_Thread): self._active = False self._handle = receiver_handle - - self._tasks = _Queue(1) - self._backup_unhandled_hook = _base.unhandled_hook - _base.unhandled_hook = self.unhandled_hook - - self._events = _Queue(32) - self._dispatcher = _Thread(group='Unifying Receiver', - name=self.__class__.__name__ + '-dispatch', - target=_event_dispatch, args=(self, events_callback)) - self._dispatcher.daemon = True + self._queued_events = _Queue(32) + self._events_callback = events_callback def run(self): self._active = True - _log.debug("started") - _base.request_context = self - _base.unhandled_hook = self._backup_unhandled_hook - del self._backup_unhandled_hook - - self._dispatcher.start() + _base.unhandled_hook = self._unhandled_hook + ihandle = int(self._handle) + _log.info("started with %s (%d)", repr(self._handle), ihandle) while self._active: - try: - # _log.debug("read next event") - event = _base.read(self._handle, _READ_EVENT_TIMEOUT) - except _NoReceiver: - self._handle = 0 - _log.warn("receiver disconnected") - self._events.put(_Packet(0xFF, 0xFF, None)) - self._active = False + if self._queued_events.empty(): + try: + # _log.debug("read next event") + event = _base.read(ihandle) + # shortcut: we should only be looking at events for proper device numbers + except _NoReceiver: + self._active = False + self._handle = None + _log.warning("receiver disconnected") + event = (0xFF, 0xFF, None) else: - if event is not None: - matched = False - task = None if self._tasks.empty() else self._tasks.queue[0] - if task and task[-1] is None: - task_dev, task_data = task[:2] - if event[1] == task_dev: - # _log.debug("matching %s to (%d, %s)", event, task_dev, repr(task_data)) - matched = event[2][:2] == task_data[:2] or (event[2][:1] in b'\x8F\xFF' and event[2][1:3] == task_data[:2]) + # deliver any queued events + event = self._queued_events.get() - if matched: - # _log.debug("request reply %s", event) - task[-1] = event - self._tasks.task_done() - else: - event = _Packet(*event) - _log.info("queueing event %s", event) - self._events.put(event) + if event: + event = _Packet(*event) + # _log.debug("processing event %s", event) + try: + self._events_callback(event) + except: + _log.exception("processing event %s", event) - _base.request_context = None - handle, self._handle = self._handle, 0 - _base.close(handle) - _log.debug("stopped") + _base.unhandled_hook = None + handle, self._handle = self._handle, None + if handle: + _base.close(handle) + _log.info("stopped %s", repr(handle)) def stop(self): """Tells the listener to stop as soon as possible.""" if self._active: _log.debug("stopping") self._active = False - # wait for the receiver handle to be closed - self.join() + handle, self._handle = self._handle, None + if handle: + _base.close(handle) + _log.info("stopped %s", repr(handle)) @property def handle(self): return self._handle - def write(self, handle, devnumber, data): - assert handle == self._handle - # _log.debug("write %02X %s", devnumber, _base._hex(data)) - task = [devnumber, data, None] - self._tasks.put(task) - _base.write(self._handle, devnumber, data) - # _log.debug("task queued %s", task) - - def read(self, handle, timeout=_base.DEFAULT_TIMEOUT): - assert handle == self._handle - # _log.debug("read %d", timeout) - assert not self._tasks.empty() - self._tasks.join() - task = self._tasks.get(False) - # _log.debug("task ready %s", task) - return task[-1] - - def unhandled_hook(self, reply_code, devnumber, data): - event = _Packet(reply_code, devnumber, data) - _log.info("queueing unhandled event %s", event) - self._events.put(event) + def _unhandled_hook(self, reply_code, devnumber, data): + # only consider unhandled events that were sent from this thread, + # i.e. triggered during a callback of a previous event + if _threading.current_thread() == self: + event = _Packet(reply_code, devnumber, data) + _log.info("queueing unhandled event %s", event) + self._queued_events.put(event) def __bool__(self): return bool(self._active and self._handle) diff --git a/lib/logitech/unifying_receiver/tests/test_30_base.py b/lib/logitech/unifying_receiver/tests/test_30_base.py index 45f87532..f7902e42 100644 --- a/lib/logitech/unifying_receiver/tests/test_30_base.py +++ b/lib/logitech/unifying_receiver/tests/test_30_base.py @@ -30,16 +30,16 @@ class Test_UR_Base(unittest.TestCase): # self.assertIsInstance(rawdevices, Iterable, "list_receiver_devices should have returned an iterable") Test_UR_Base.ur_available = len(list(rawdevices)) > 0 - def test_20_try_open(self): + def test_20_open_path(self): if not self.ur_available: self.fail("No receiver found") for rawdevice in base.list_receiver_devices(): - handle = base.try_open(rawdevice.path) + handle = base.open_path(rawdevice.path) if handle is None: continue - self.assertIsInstance(handle, int, "try_open should have returned an int") + self.assertIsInstance(handle, int, "open_path should have returned an int") if Test_UR_Base.handle is None: Test_UR_Base.handle = handle @@ -131,46 +131,46 @@ class Test_UR_Base(unittest.TestCase): index = reply[:1] self.assertGreater(index, b'\x00', "FEATURE_SET not available on device " + str(self.device)) - def test_57_request_ignore_undhandled(self): - if self.handle is None: - self.fail("No receiver found") - if self.device is None: - self.fail("No devices attached") + # def test_57_request_ignore_undhandled(self): + # if self.handle is None: + # self.fail("No receiver found") + # if self.device is None: + # self.fail("No devices attached") - fs_index = base.request(self.handle, self.device, FEATURE.ROOT, FEATURE.FEATURE_SET) - self.assertIsNotNone(fs_index) - fs_index = fs_index[:1] - self.assertGreater(fs_index, b'\x00') + # fs_index = base.request(self.handle, self.device, FEATURE.ROOT, FEATURE.FEATURE_SET) + # self.assertIsNotNone(fs_index) + # fs_index = fs_index[:1] + # self.assertGreater(fs_index, b'\x00') - global received_unhandled - received_unhandled = None + # global received_unhandled + # received_unhandled = None - def _unhandled(code, device, data): - self.assertIsNotNone(code) - self.assertIsInstance(code, int) - self.assertIsNotNone(device) - self.assertIsInstance(device, int) - self.assertIsNotNone(data) - self.assertIsInstance(data, str) - global received_unhandled - received_unhandled = (code, device, data) + # def _unhandled(code, device, data): + # self.assertIsNotNone(code) + # self.assertIsInstance(code, int) + # self.assertIsNotNone(device) + # self.assertIsInstance(device, int) + # self.assertIsNotNone(data) + # self.assertIsInstance(data, str) + # global received_unhandled + # received_unhandled = (code, device, data) - base.unhandled_hook = _unhandled - base.write(self.handle, self.device, FEATURE.ROOT + FEATURE.FEATURE_SET) - reply = base.request(self.handle, self.device, fs_index + b'\x00') - self.assertIsNotNone(reply, "request returned None reply") - self.assertNotEquals(reply[:1], b'\x00') - self.assertIsNotNone(received_unhandled, "extra message not received by unhandled hook") + # base.unhandled_hook = _unhandled + # base.write(self.handle, self.device, FEATURE.ROOT + FEATURE.FEATURE_SET) + # reply = base.request(self.handle, self.device, fs_index + b'\x00') + # self.assertIsNotNone(reply, "request returned None reply") + # self.assertNotEquals(reply[:1], b'\x00') + # self.assertIsNotNone(received_unhandled, "extra message not received by unhandled hook") - received_unhandled = None - base.unhandled_hook = None - base.write(self.handle, self.device, FEATURE.ROOT + FEATURE.FEATURE_SET) - reply = base.request(self.handle, self.device, fs_index + b'\x00') - self.assertIsNotNone(reply, "request returned None reply") - self.assertNotEquals(reply[:1], b'\x00') - self.assertIsNone(received_unhandled) + # received_unhandled = None + # base.unhandled_hook = None + # base.write(self.handle, self.device, FEATURE.ROOT + FEATURE.FEATURE_SET) + # reply = base.request(self.handle, self.device, fs_index + b'\x00') + # self.assertIsNotNone(reply, "request returned None reply") + # self.assertNotEquals(reply[:1], b'\x00') + # self.assertIsNone(received_unhandled) - del received_unhandled + # del received_unhandled # def test_90_receiver_missing(self): # if self.handle is None: diff --git a/lib/logitech/unifying_receiver/tests/test_50_api.py b/lib/logitech/unifying_receiver/tests/test_50_api.py index c2ee9792..9d73d13b 100644 --- a/lib/logitech/unifying_receiver/tests/test_50_api.py +++ b/lib/logitech/unifying_receiver/tests/test_50_api.py @@ -41,7 +41,8 @@ class Test_UR_API(unittest.TestCase): def test_05_ping_device_zero(self): self._check(check_device=False) - ok = api.ping(self.receiver.handle, 0) + d = api.PairedDevice(self.receiver.handle, 0) + ok = d.ping() self.assertIsNotNone(ok, "invalid ping reply") self.assertFalse(ok, "device zero replied") @@ -51,7 +52,8 @@ class Test_UR_API(unittest.TestCase): devices = [] for devnumber in range(1, 1 + MAX_ATTACHED_DEVICES): - ok = api.ping(self.receiver.handle, devnumber) + d = api.PairedDevice(self.receiver.handle, devnumber) + ok = d.ping() self.assertIsNotNone(ok, "invalid ping reply") if ok: devices.append(self.receiver[devnumber])