From b10ade44303ddb678d0d7b1daf4a42acfe2dd118 Mon Sep 17 00:00:00 2001 From: Daniel Pavel Date: Mon, 22 Oct 2012 10:03:16 +0300 Subject: [PATCH] initial implementation of pairing --- app/pairing.py | 103 +++++++++++++++ app/receiver.py | 32 +++-- app/solaar.py | 79 ++++++------ app/ui/__init__.py | 57 +++++++- app/ui/action.py | 58 +++++++++ app/ui/icon.py | 79 ------------ app/ui/{window.py => main_window.py} | 111 +++++----------- app/ui/notify.py | 14 +- app/ui/pair.py | 15 --- app/ui/pair_window.py | 122 ++++++++++++++++++ app/ui/status_icon.py | 56 ++++++++ app/watcher.py | 94 +++++++------- lib/logitech/devices/__init__.py | 4 +- lib/logitech/devices/k750.py | 2 +- lib/logitech/unifying_receiver/api.py | 13 +- lib/logitech/unifying_receiver/listener.py | 8 +- .../hicolor/128x128/status/battery_000.png | Bin 6760 -> 8360 bytes .../hicolor/48x48/status/image-missing.png | Bin 2502 -> 0 bytes 18 files changed, 554 insertions(+), 293 deletions(-) create mode 100644 app/pairing.py create mode 100644 app/ui/action.py delete mode 100644 app/ui/icon.py rename app/ui/{window.py => main_window.py} (70%) delete mode 100644 app/ui/pair.py create mode 100644 app/ui/pair_window.py create mode 100644 app/ui/status_icon.py delete mode 100644 share/icons/hicolor/48x48/status/image-missing.png diff --git a/app/pairing.py b/app/pairing.py new file mode 100644 index 00000000..1a0b45f5 --- /dev/null +++ b/app/pairing.py @@ -0,0 +1,103 @@ +# +# +# + +from logging import getLogger as _Logger + +from receiver import DeviceInfo as _DeviceInfo +from logitech.devices.constants import (STATUS, NAMES) + +_l = _Logger('pairing') + + +class State(object): + TICK = 300 + PAIR_TIMEOUT = 60 * 1000 / TICK + + def __init__(self, watcher): + self._watcher = watcher + self.reset() + + def reset(self): + self.success = None + self.detected_device = None + self._countdown = self.PAIR_TIMEOUT + + def countdown(self, assistant): + if self._countdown == self.PAIR_TIMEOUT: + self.start_scan() + self._countdown -= 1 + return True + + self._countdown -= 1 + if self._countdown > 0 and self.success is None: + return True + + self.stop_scan() + assistant.scan_complete(assistant, self.detected_device) + return False + + def start_scan(self): + self.reset() + self._watcher.receiver.events_filter = self.filter_events + reply = self._watcher.receiver.request(0xFF, b'\x80\xB2', b'\x01') + _l.debug("start scan reply %s", repr(reply)) + + def stop_scan(self): + if self._countdown >= 0: + self._countdown = -1 + + reply = self._watcher.receiver.request(0xFF, b'\x80\xB2', b'\x02') + _l.debug("stop scan reply %s", repr(reply)) + self._watcher.receiver.events_filter = None + + def filter_events(self, event): + if event.devnumber == 0xFF: + if event.code == 0x10: + if event.data == b'\x4A\x01\x00\x00\x00': + _l.debug("receiver listening for device wakeup") + return True + if event.data == b'\x4A\x00\x01\x00\x00': + _l.debug("receiver gave up") + self.success = False + return True + return False + + if event.devnumber in self._watcher.receiver.devices: + return False + + _l.debug("event for new device? %s", event) + if event.code == 0x10 and event.data[0:2] == b'\x41\x04': + state_code = ord(event.data[2:3]) & 0xF0 + state = STATUS.UNAVAILABLE if state_code == 0x60 else \ + STATUS.CONNECTED if state_code == 0xA0 else \ + STATUS.CONNECTED if state_code == 0x20 else \ + None + if state is None: + _l.warn("don't know how to handle status 0x%02x: %s", state_code, event) + elif event.devnumber < 1 or event.devnumber > self.max_devices: + _l.warn("got event for invalid device number %d: %s", event.devnumber, event) + else: + dev = _DeviceInfo(self._watcher.receiver, event.devnumber, state) + if state == STATUS.CONNECTED: + n, k = dev.name, dev.kind + _l.debug("detected active device %s", dev) + else: + # we can query the receiver for the device short name + dev_id = self.request(0xFF, b'\x83\xB5', event.data[4:5]) + if dev_id: + shortname = str(dev_id[2:].rstrip(b'\x00')) + if shortname in NAMES: + dev._name, dev._kind = NAMES[shortname] + _l.debug("detected new device %s", dev) + else: + _l.warn("could not properly detect inactive device %d: %s", event.devnumber, shortname) + self.detected_device = dev + + return True + + +def unpair(receiver, devnumber): + reply = receiver.request(0xFF, b'\x80\xB2', b'\x03' + chr(devnumber)) + _l.debug("unpair %d reply %s", devnumber, repr(reply)) + diff --git a/app/receiver.py b/app/receiver.py index c4c2164f..49545874 100644 --- a/app/receiver.py +++ b/app/receiver.py @@ -66,7 +66,7 @@ class DeviceInfo(object): def name(self): if self._name is None: if self._status >= STATUS.CONNECTED: - self._name = self.receiver.request(_api.get_device_name, self.number, self.features) + self._name = self.receiver.call_api(_api.get_device_name, self.number, self.features) return self._name or '?' @property @@ -77,25 +77,25 @@ class DeviceInfo(object): def kind(self): if self._kind is None: if self._status >= STATUS.CONNECTED: - self._kind = self.receiver.request(_api.get_device_kind, self.number, self.features) + self._kind = self.receiver.call_api(_api.get_device_kind, self.number, self.features) return self._kind or '?' @property def firmware(self): if self._firmware is None: if self._status >= STATUS.CONNECTED: - self._firmware = self.receiver.request(_api.get_device_firmware, self.number, self.features) + self._firmware = self.receiver.call_api(_api.get_device_firmware, self.number, self.features) return self._firmware or () @property def features(self): if self._features is None: if self._status >= STATUS.CONNECTED: - self._features = self.receiver.request(_api.get_device_features, self.number) + self._features = self.receiver.call_api(_api.get_device_features, self.number) return self._features or () def ping(self): - return self.receiver.request(_api.ping, self.number) + return self.receiver.call_api(_api.ping, self.number) def process_event(self, code, data): if code == 0x10 and data[:1] == b'\x8F': @@ -155,12 +155,11 @@ class Receiver(_listener.EventsListener): self.LOG.info("initializing") self.devices = {} + self.events_filter = None self.events_handler = None - init = (_base.request(handle, 0xFF, b'\x81\x00') and - _base.request(handle, 0xFF, b'\x80\x00', b'\x00\x01') and - _base.request(handle, 0xFF, b'\x81\x02')) - if init: + if (_base.request(handle, 0xFF, b'\x81\x00') and + _base.request(handle, 0xFF, b'\x80\x00', b'\x00\x01')): self.LOG.info("initialized") else: self.LOG.warn("initialization failed") @@ -210,12 +209,18 @@ class Receiver(_listener.EventsListener): def device_name(self): return self.NAME + def count_devices(self): + return self.call_api(_api.count_devices) + def _device_changed(self, dev, urgent=False): self.status_changed.reason = dev self.status_changed.urgent = urgent self.status_changed.set() def _events_handler(self, event): + if self.events_filter and self.events_filter(event): + return + if event.code == 0x10 and event.data[0:2] == b'\x41\x04': state_code = ord(event.data[2:3]) & 0xF0 state = STATUS.UNAVAILABLE if state_code == 0x60 else \ @@ -239,7 +244,7 @@ class Receiver(_listener.EventsListener): n, k = dev.name, dev.kind else: # we can query the receiver for the device short name - dev_id = self.request(_base.request, 0xFF, b'\x83\xB5', event.data[4:5]) + dev_id = self.request(0xFF, b'\x83\xB5', event.data[4:5]) if dev_id: shortname = str(dev_id[2:].rstrip(b'\x00')) if shortname in NAMES: @@ -258,14 +263,15 @@ class Receiver(_listener.EventsListener): self.devices = {} self.status = STATUS.UNAVAILABLE return - self.LOG.warn("don't know how to handle event %s", event) elif event.devnumber in self.devices: dev = self.devices[event.devnumber] if dev.process_event(event.code, event.data): return - if self.events_handler: - self.events_handler(event) + if self.events_handler and self.events_handler(event): + return + + self.LOG.warn("don't know how to handle event %s", event) def __str__(self): return 'Receiver(%s,%x,%d:%d)' % (self.path, self._handle, self._active, self._status) diff --git a/app/solaar.py b/app/solaar.py index 2c0628ca..0d4806bd 100644 --- a/app/solaar.py +++ b/app/solaar.py @@ -1,17 +1,19 @@ #!/usr/bin/env python +__author__ = "Daniel Pavel " __version__ = '0.5' +__license__ = "GPL" # # # -APP_TITLE = 'Solaar' +APPNAME = 'Solaar' -if __name__ == '__main__': +def _parse_arguments(): import argparse - arg_parser = argparse.ArgumentParser(prog=APP_TITLE) + arg_parser = argparse.ArgumentParser(prog=APPNAME.lower()) arg_parser.add_argument('-v', '--verbose', action='count', default=0, help='increase the logger verbosity (may be repeated)') @@ -31,59 +33,50 @@ if __name__ == '__main__': import logging log_level = logging.root.level - 10 * args.verbose log_format='%(asctime)s.%(msecs)03d %(levelname)8s [%(threadName)s] %(name)s: %(message)s' - logging.basicConfig(level=log_level if log_level > 0 else 1, format=log_format, datefmt='%H:%M:%S') + logging.basicConfig(level=log_level if log_level > 0 else 1, + format=log_format, + datefmt='%H:%M:%S') - from gi.repository import GObject - GObject.threads_init() + return args + + +if __name__ == '__main__': + args = _parse_arguments() import ui + # check if the notifications are available args.notifications &= args.systray - if args.notifications: - args.notifications &= ui.notify.init(APP_TITLE) + if ui.notify.init(APPNAME): + ui.action.toggle_notifications.set_active(args.notifications) + else: + ui.action.toggle_notifications = None import watcher - tray_icon = None - window = ui.window.create(APP_TITLE, - watcher.DUMMY.NAME, - watcher.DUMMY.max_devices, - args.systray) - window.set_icon_name(APP_TITLE + '-init') - def _ui_update(receiver, tray_icon, window): - icon_name = APP_TITLE + '-fail' if receiver.status < 1 else APP_TITLE - if window: - GObject.idle_add(ui.window.update, window, receiver, icon_name) - if tray_icon: - GObject.idle_add(ui.icon.update, tray_icon, receiver, icon_name) - - def _notify(device): - GObject.idle_add(ui.notify.show, device) - - w = watcher.Watcher(APP_TITLE, - lambda r: _ui_update(r, tray_icon, window), - _notify if args.notifications else None) - w.start() + window = ui.main_window.create(APPNAME, + watcher.DUMMY.NAME, + watcher.DUMMY.max_devices, + args.systray) if args.systray: - def _toggle_notifications(item): - # logging.debug("toggle notifications %s", item) - if ui.notify.available: - if item.get_active(): - ui.notify.init(APP_TITLE) - else: - ui.notify.uninit() - item.set_sensitive(ui.notify.available) - - menu = ( - ('Notifications', _toggle_notifications if args.notifications else None, args.notifications), - ) - - tray_icon = ui.icon.create(APP_TITLE, (ui.window.toggle, window), menu) - tray_icon.set_from_icon_name(APP_TITLE + '-init') + menu_actions = (ui.action.pair, + ui.action.toggle_notifications, + ui.action.about) + icon = ui.status_icon.create(window, menu_actions) else: + icon = None window.present() + w = watcher.Watcher(APPNAME, + lambda r: ui.update(r, icon, window), + ui.notify.show if ui.notify.available else None) + w.start() + + import pairing + ui.action.pair.connect('activate', ui.action._pair_device, + window, pairing.State(w)) + from gi.repository import Gtk Gtk.main() diff --git a/app/ui/__init__.py b/app/ui/__init__.py index bacb7d14..4552c6ff 100644 --- a/app/ui/__init__.py +++ b/app/ui/__init__.py @@ -1,3 +1,58 @@ # pass -from . import (notify, icon, window, pair) +APPNAME = 'Solaar' +APPVERSION = '0.5' + +from . import (notify, status_icon, main_window, pair_window, action) + +from gi.repository import (GObject, Gtk) +GObject.threads_init() + + +def appicon(receiver_status): + return (APPNAME + '-fail' if receiver_status < 0 else + APPNAME + '-init' if receiver_status < 1 else + APPNAME) + + +_THEME = Gtk.IconTheme.get_default() + +def get_icon(name, fallback): + return name if name and _THEME.has_icon(name) else fallback + +def icon_file(name): + if name and _THEME.has_icon(name): + return _THEME.lookup_icon(name, 0, 0).get_filename() + return None + + +def find_children(container, *child_names): + def _iterate_children(widget, names, result, count): + wname = widget.get_name() + if wname in names: + index = names.index(wname) + names[index] = None + result[index] = widget + count -= 1 + + if count > 0 and isinstance(widget, Gtk.Container): + for w in widget: + count = _iterate_children(w, names, result, count) + if count == 0: + break + + return count + + names = list(child_names) + count = len(names) + result = [None] * count + _iterate_children(container, names, result, count) + return tuple(result) if count > 1 else result[0] + + +def update(receiver, icon, window): + GObject.idle_add(action.pair.set_sensitive, receiver.status > 0) + if window: + GObject.idle_add(main_window.update, window, receiver) + if icon: + GObject.idle_add(status_icon.update, icon, receiver) diff --git a/app/ui/action.py b/app/ui/action.py new file mode 100644 index 00000000..11ff8bf4 --- /dev/null +++ b/app/ui/action.py @@ -0,0 +1,58 @@ + +from gi.repository import Gtk + +import ui + + +def _action(name, label, function, *args): + action = Gtk.Action(name, label, label, None) + action.set_icon_name(name) + if function: + action.connect('activate', function, *args) + return action + + +def _toggle_action(name, label, function, *args): + action = Gtk.ToggleAction(name, label, label, None) + action.set_icon_name(name) + action.connect('activate', function, *args) + return action + + +# +# +# + + +def _toggle_notifications(action): + if action.get_active(): + ui.notify.init(ui.APPNAME) + else: + ui.notify.uninit() + action.set_sensitive(ui.notify.available) +toggle_notifications = _toggle_action('notifications', 'Notifications', _toggle_notifications) + + +def _show_about_window(action): + about = Gtk.AboutDialog() + about.set_icon_name(ui.APPNAME) + about.set_program_name(ui.APPNAME) + about.set_logo_icon_name(ui.APPNAME) + about.set_version(ui.APPVERSION) + about.set_license_type(Gtk.License.GPL_2_0) + about.set_authors(('Daniel Pavel http://github.com/pwr', )) + about.set_website('http://github.com/pwr/Solaar/wiki') + about.run() + about.destroy() +about = _action('help-about', 'About ' + ui.APPNAME, _show_about_window) + + +def _pair_device(action, window, state): + action.set_sensitive(False) + pair_dialog = ui.pair_window.create(action, state) + # window.present() + # pair_dialog.set_transient_for(parent_window) + # pair_dialog.set_destroy_with_parent(parent_window) + # pair_dialog.set_modal(True) + pair_dialog.present() +pair = _action('add', 'Pair new device', None) diff --git a/app/ui/icon.py b/app/ui/icon.py deleted file mode 100644 index b54ee904..00000000 --- a/app/ui/icon.py +++ /dev/null @@ -1,79 +0,0 @@ -# -# -# - -from gi.repository import Gtk - - -def create(title, click_action=None, actions=None): - icon = Gtk.StatusIcon() - icon.set_title(title) - icon.set_name(title) - - if click_action: - if type(click_action) == tuple: - function = click_action[0] - args = click_action[1:] - icon.connect('activate', function, *args) - else: - icon.connect('activate', click_action) - - menu = Gtk.Menu() - - if actions: - for name, activate, checked in actions: - if checked is None: - item = Gtk.MenuItem(name) - if activate is None: - item.set_sensitive(False) - else: - item.connect('activate', activate) - else: - item = Gtk.CheckMenuItem(name) - if activate is None: - item.set_sensitive(False) - else: - item.set_active(checked or False) - item.connect('toggled', activate) - - menu.append(item) - menu.append(Gtk.SeparatorMenuItem()) - - quit_item = Gtk.MenuItem('Quit') - quit_item.connect('activate', Gtk.main_quit) - menu.append(quit_item) - - menu.show_all() - - icon.connect('popup_menu', - lambda icon, button, time, menu: - menu.popup(None, None, icon.position_menu, icon, button, time), - menu) - - return icon - - -def update(icon, receiver, icon_name=None): - if icon_name is not None: - icon.set_from_icon_name(icon_name) - - if receiver.devices: - lines = [] - if receiver.status < 1: - lines += (receiver.status_text, '') - - devlist = [receiver.devices[d] for d in range(1, 1 + receiver.max_devices) if d in receiver.devices] - for dev in devlist: - name = '' + dev.name + '' - if dev.status < 1: - lines.append(name + ' (' + dev.status_text + ')') - else: - lines.append(name) - if dev.status > 1: - lines.append(' ' + dev.status_text) - lines.append('') - - text = '\n'.join(lines).rstrip('\n') - icon.set_tooltip_markup(text) - else: - icon.set_tooltip_text(receiver.status_text) diff --git a/app/ui/window.py b/app/ui/main_window.py similarity index 70% rename from app/ui/window.py rename to app/ui/main_window.py index a0e7d9cb..b3761283 100644 --- a/app/ui/window.py +++ b/app/ui/main_window.py @@ -4,6 +4,7 @@ from gi.repository import (Gtk, Gdk) +import ui from logitech.devices.constants import (STATUS, PROPS) @@ -13,37 +14,10 @@ _STATUS_ICON_SIZE = Gtk.IconSize.DND _PLACEHOLDER = '~' -theme = Gtk.IconTheme.get_default() - - -def _find_children(container, *child_names): - def _iterate_children(widget, names, result, count): - wname = widget.get_name() - if wname in names: - index = names.index(wname) - names[index] = None - result[index] = widget - count -= 1 - - if count > 0 and isinstance(widget, Gtk.Container): - for w in widget: - count = _iterate_children(w, names, result, count) - if count == 0: - break - - return count - - names = list(child_names) - count = len(names) - result = [None] * count - _iterate_children(container, names, result, count) - return result if count > 1 else result[0] - - def _update_receiver_box(box, receiver): - label, buttons = _find_children(box, 'label', 'buttons') + label, buttons = ui.find_children(box, 'label', 'buttons') label.set_text(receiver.status_text or '') - buttons.set_visible(receiver.status >= STATUS.CONNECTED) + # buttons.set_visible(receiver.status >= STATUS.CONNECTED) def _update_device_box(frame, dev): @@ -52,19 +26,16 @@ def _update_device_box(frame, dev): frame.set_name(_PLACEHOLDER) return - icon, label = _find_children(frame, 'icon', 'label') + icon, label = ui.find_children(frame, 'icon', 'label') frame.set_visible(True) if frame.get_name() != dev.name: frame.set_name(dev.name) - if theme.has_icon(dev.name): - icon.set_from_icon_name(dev.name, _DEVICE_ICON_SIZE) - else: - icon.set_from_icon_name(dev.kind, _DEVICE_ICON_SIZE) + icon.set_from_icon_name(ui.get_icon(dev.name, dev.kind), _DEVICE_ICON_SIZE) icon.set_tooltip_text(dev.name) label.set_markup('' + dev.name + '') - status = _find_children(frame, 'status') + status = ui.find_children(frame, 'status') if dev.status < STATUS.CONNECTED: icon.set_sensitive(False) icon.set_tooltip_text(dev.status_text) @@ -111,10 +82,9 @@ def _update_device_box(frame, dev): light_label.set_text('%d lux' % light_level) -def update(window, receiver, icon_name=None): +def update(window, receiver): if window and window.get_child(): - if icon_name is not None: - window.set_icon_name(icon_name) + window.set_icon_name(ui.appicon(receiver.status)) vbox = window.get_child() controls = list(vbox.get_children()) @@ -132,7 +102,7 @@ def update(window, receiver, icon_name=None): def _receiver_box(name): box = _device_box(False, False) - icon, status_box = _find_children(box, 'icon', 'status') + icon, status_box = ui.find_children(box, 'icon', 'status') icon.set_from_icon_name(name, _SMALL_DEVICE_ICON_SIZE) icon.set_tooltip_text(name) @@ -142,14 +112,10 @@ def _receiver_box(name): toolbar.set_icon_size(Gtk.IconSize.MENU) toolbar.set_show_arrow(False) - pair_button = Gtk.ToolButton() - pair_button.set_icon_name('add') - pair_button.set_tooltip_text('Pair new device') - pair_button.set_sensitive(False) - toolbar.insert(pair_button, 0) + toolbar.insert(ui.action.pair.create_tool_item(), 0) toolbar.show_all() - toolbar.set_visible(False) + # toolbar.set_visible(False) status_box.pack_end(toolbar, False, False, 0) return box @@ -208,10 +174,26 @@ def _device_box(has_status_icons=True, has_frame=True): return box +def toggle(window, trigger): + # print 'window toggle', window, trigger + if window.get_visible(): + position = window.get_position() + window.hide() + window.move(*position) + else: + if trigger and type(trigger) == Gtk.StatusIcon: + x, y = window.get_position() + if x == 0 and y == 0: + x, y, _ = Gtk.StatusIcon.position_menu(Gtk.Menu(), trigger) + window.move(x, y) + window.present() + return True + + def create(title, name, max_devices, systray=False): window = Gtk.Window() window.set_title(title) - # window.set_icon_name(title) + window.set_icon_name(ui.appicon(0)) window.set_role('status-window') vbox = Gtk.VBox(homogeneous=False, spacing=4) @@ -227,46 +209,17 @@ def create(title, name, max_devices, systray=False): window.add(vbox) geometry = Gdk.Geometry() - geometry.min_width = 260 + geometry.min_width = 300 geometry.min_height = 40 window.set_geometry_hints(vbox, geometry, Gdk.WindowHints.MIN_SIZE) window.set_resizable(False) + window.toggle_visible = lambda i: toggle(window, i) + if systray: - # def _state_event(w, e): - # if e.new_window_state & Gdk.WindowState.ICONIFIED: - # w.hide() - # w.deiconify() - # return True - # window.connect('window-state-event', _state_event) - window.set_keep_above(True) - window.set_deletable(False) - # window.set_decorated(False) - # window.set_position(Gtk.WindowPosition.MOUSE) - # ulgy, but hides the minimize icon from the window - window.set_type_hint(Gdk.WindowTypeHint.MENU) - window.set_skip_taskbar_hint(True) - window.set_skip_pager_hint(True) - - window.connect('delete-event', lambda w, e: toggle(None, w) or True) + window.connect('delete-event', toggle) else: - # window.set_position(Gtk.WindowPosition.CENTER) window.connect('delete-event', Gtk.main_quit) return window - - -def toggle(icon, window): - if window.get_visible(): - position = window.get_position() - window.hide() - window.move(*position) - else: - if icon: - x, y = window.get_position() - if x == 0 and y == 0: - x, y, _ = Gtk.StatusIcon.position_menu(Gtk.Menu(), icon) - window.move(x, y) - window.present() - return True diff --git a/app/ui/notify.py b/app/ui/notify.py index f2c28e0e..4bdd9a0b 100644 --- a/app/ui/notify.py +++ b/app/ui/notify.py @@ -7,18 +7,16 @@ import logging try: from gi.repository import Notify - from gi.repository import Gtk + import ui from logitech.devices.constants import STATUS # necessary because the notifications daemon does not know about our XDG_DATA_DIRS - theme = Gtk.IconTheme.get_default() _icons = {} def _icon(title): if title not in _icons: - icon = theme.lookup_icon(title, 0, 0) - _icons[title] = icon.get_filename() if icon else None + _icons[title] = ui.icon_file(title) return _icons.get(title) @@ -28,14 +26,14 @@ try: _notifications = {} - def init(app_title=None): + def init(app_title): """Init the notifications system.""" global available if available: - logging.info("starting desktop notifications") if not Notify.is_initted(): + logging.info("starting desktop notifications") try: - return Notify.init(app_title or Notify.get_app_name()) + return Notify.init(app_title) except: logging.exception("initializing desktop notifications") available = False @@ -74,4 +72,4 @@ except ImportError: available = False init = lambda app_title: False uninit = lambda: None - show = lambda status_code, title, text: None + show = lambda dev: None diff --git a/app/ui/pair.py b/app/ui/pair.py deleted file mode 100644 index a91aec09..00000000 --- a/app/ui/pair.py +++ /dev/null @@ -1,15 +0,0 @@ -# -# -# - -from gi.repository import Gtk - - -def create(parent_window, title): - window = Gtk.Dialog(title, parent_window, Gtk.DialogFlags.MODAL, buttons=(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL)) - - Gtk.Window.set_default_icon_name('add') - window.set_resizable(False) - # window.set_role('pair-device') - - return window diff --git a/app/ui/pair_window.py b/app/ui/pair_window.py new file mode 100644 index 00000000..06bcc442 --- /dev/null +++ b/app/ui/pair_window.py @@ -0,0 +1,122 @@ +# +# +# + +import logging +from gi.repository import (Gtk, GObject) + +import ui + + +def _create_page(assistant, text, kind): + p = Gtk.VBox(False, 12) + p.set_border_width(8) + + if text: + label = Gtk.Label(text) + label.set_alignment(0, 0) + p.pack_start(label, False, True, 0) + + assistant.append_page(p) + assistant.set_page_type(p, kind) + + p.show_all() + return p + + +def _device_confirmed(entry, _2, trigger, assistant, page): + assistant.commit() + assistant.set_page_complete(page, True) + return True + + +def _finish(assistant, action): + logging.debug("finish %s", assistant) + assistant.destroy() + action.set_sensitive(True) + +def _cancel(assistant, action, state): + logging.debug("cancel %s", assistant) + state.stop_scan() + _finish(assistant, action) + +def _prepare(assistant, page, state): + index = assistant.get_current_page() + logging.debug("prepare %s %d %s", assistant, index, page) + + if index == 0: + state.reset() + GObject.timeout_add(state.TICK, state.countdown, assistant) + spinner = page.get_children()[-1] + spinner.start() + return + + assistant.remove_page(0) + state.stop_scan() + +def _scan_complete(assistant, device): + if device is None: + 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) + else: + page = _create_page(assistant, + None, + Gtk.AssistantPageType.CONFIRM) + + hbox = Gtk.HBox(False, 16) + device_icon = Gtk.Image() + device_icon.set_from_icon_name(ui.get_icon(device.name, device.kind), Gtk.IconSize.DIALOG) + hbox.pack_start(device_icon, False, False, 0) + device_label = Gtk.Label(device.kind + '\n' + device.name) + hbox.pack_start(device_label, False, False, 0) + halign = Gtk.Alignment.new(0.5, 0.5, 0, 1) + halign.add(hbox) + page.pack_start(halign, False, True, 0) + + hbox = Gtk.HBox(False, 16) + hbox.pack_start(Gtk.Entry(), False, False, 0) + hbox.pack_start(Gtk.ToggleButton('Test'), False, False, 0) + halign = Gtk.Alignment.new(0.5, 0.5, 0, 1) + halign.add(hbox) + page.pack_start(halign, False, False, 0) + + entry_info = Gtk.Label('Use the controls above to confirm\n' + 'this is the device you want to pair.') + entry_info.set_sensitive(False) + page.pack_start(entry_info, False, False, 0) + + page.show_all() + assistant.set_page_complete(page, True) + + assistant.next_page() + + +def create(action, state): + assistant = Gtk.Assistant() + assistant.set_title(action.get_label()) + assistant.set_icon_name(action.get_icon_name()) + + assistant.set_size_request(440, 240) + assistant.set_resizable(False) + assistant.set_role('pair-device') + + page_intro = _create_page(assistant, + '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) + spinner = Gtk.Spinner() + spinner.set_visible(True) + page_intro.pack_end(spinner, True, True, 16) + + assistant.scan_complete = _scan_complete + + assistant.connect('prepare', _prepare, state) + assistant.connect('cancel', _cancel, action, state) + assistant.connect('close', _finish, action) + assistant.connect('apply', _finish, action) + + return assistant diff --git a/app/ui/status_icon.py b/app/ui/status_icon.py new file mode 100644 index 00000000..654d3a1f --- /dev/null +++ b/app/ui/status_icon.py @@ -0,0 +1,56 @@ +# +# +# + +from gi.repository import Gtk +import ui + + +def create(window, menu_actions=None): + icon = Gtk.StatusIcon() + icon.set_title(window.get_title()) + icon.set_name(window.get_title()) + icon.set_from_icon_name(ui.appicon(0)) + + icon.connect('activate', window.toggle_visible) + + menu = Gtk.Menu() + for action in menu_actions or (): + if action: + menu.append(action.create_menu_item()) + + quit_action = ui.action._action('exit', 'Quit', Gtk.main_quit) + menu.append(quit_action.create_menu_item()) + menu.show_all() + + icon.connect('popup_menu', + lambda icon, button, time, menu: + menu.popup(None, None, icon.position_menu, icon, button, time), + menu) + + return icon + + +def update(icon, receiver): + icon.set_from_icon_name(ui.appicon(receiver.status)) + + if receiver.devices: + lines = [] + if receiver.status < 1: + lines += (receiver.status_text, '') + + devlist = [receiver.devices[d] for d in range(1, 1 + receiver.max_devices) if d in receiver.devices] + for dev in devlist: + name = '' + dev.name + '' + if dev.status < 1: + lines.append(name + ' (' + dev.status_text + ')') + else: + lines.append(name) + if dev.status > 1: + lines.append(' ' + dev.status_text) + lines.append('') + + text = '\n'.join(lines).rstrip('\n') + icon.set_tooltip_markup(text) + else: + icon.set_tooltip_text(receiver.status_text) diff --git a/app/watcher.py b/app/watcher.py index 4f5766ea..4f718ee6 100644 --- a/app/watcher.py +++ b/app/watcher.py @@ -4,7 +4,7 @@ from threading import Thread import time -import logging +from logging import getLogger as _Logger from collections import namedtuple from logitech.devices.constants import STATUS @@ -16,12 +16,14 @@ _DUMMY_RECEIVER.__nonzero__ = lambda _: False _DUMMY_RECEIVER.device_name = Receiver.NAME DUMMY = _DUMMY_RECEIVER(Receiver.NAME, Receiver.NAME, STATUS.UNAVAILABLE, 'Receiver not found.', Receiver.max_devices, {}) +_l = _Logger('watcher') + def _sleep(seconds, granularity, breakout=lambda: False): - for index in range(0, int(seconds / granularity)): - if breakout(): - return + slept = 0 + while slept < seconds and not breakout(): time.sleep(granularity) + slept += granularity class Watcher(Thread): @@ -30,77 +32,77 @@ class Watcher(Thread): """ def __init__(self, apptitle, update_ui, notify=None): super(Watcher, self).__init__(group=apptitle, name='Watcher') - self.daemon = True self._active = False + self._receiver = DUMMY self.update_ui = update_ui self.notify = notify or (lambda d: None) - self.receiver = DUMMY + @property + def receiver(self): + return self._receiver def run(self): self._active = True notify_missing = True while self._active: - if self.receiver == DUMMY: + if self._receiver == DUMMY: r = Receiver.open() - if r: - logging.info("receiver %s ", r) - self.update_ui(r) - self.notify(r) - r.events_handler = self._events_callback - - # give it some time to read all devices - r.status_changed.clear() - _sleep(8, 0.4, r.status_changed.is_set) - if r.devices: - logging.info("%d device(s) found", len(r.devices)) - for d in r.devices.values(): - self.notify(d) - else: - # if no devices found so far, assume none at all - logging.info("no devices found") - r.status = STATUS.CONNECTED - - self.receiver = r - notify_missing = True - else: + if r is None: if notify_missing: _sleep(0.8, 0.4, lambda: not self._active) notify_missing = False - self.update_ui(DUMMY) - self.notify(DUMMY) + if self._active: + self.update_ui(DUMMY) + self.notify(DUMMY) _sleep(4, 0.4, lambda: not self._active) continue + _l.info("receiver %s ", r) + self.update_ui(r) + self.notify(r) + + if r.count_devices() > 0: + # give it some time to read all devices + r.status_changed.clear() + _sleep(8, 0.4, r.status_changed.is_set) + + if r.devices: + _l.info("%d device(s) found", len(r.devices)) + for d in r.devices.values(): + self.notify(d) + else: + # if no devices found so far, assume none at all + _l.info("no devices found") + r.status = STATUS.CONNECTED + + self._receiver = r + notify_missing = True + if self._active: - if self.receiver: - logging.debug("waiting for status_changed") - sc = self.receiver.status_changed + if self._receiver: + _l.debug("waiting for status_changed") + sc = self._receiver.status_changed sc.wait() sc.clear() - logging.debug("status_changed %s %d", sc.reason, sc.urgent) - self.update_ui(self.receiver) + _l.debug("status_changed %s %d", sc.reason, sc.urgent) + self.update_ui(self._receiver) if sc.reason and sc.urgent: self.notify(sc.reason) else: - self.receiver = DUMMY + self._receiver = DUMMY self.update_ui(DUMMY) self.notify(DUMMY) - if self.receiver: - self.receiver.close() + if self._receiver: + self._receiver.close() def stop(self): if self._active: - logging.info("stopping %s", self) + _l.info("stopping %s", self) self._active = False - if self.receiver: + if self._receiver: # break out of an eventual wait() - self.receiver.status_changed.reason = None - self.receiver.status_changed.set() - self.join() - - def _events_callback(self, event): - logging.warn("don't know how to handle event %s", event) + self._receiver.status_changed.reason = None + self._receiver.status_changed.set() diff --git a/lib/logitech/devices/__init__.py b/lib/logitech/devices/__init__.py index 2e463fd2..17150285 100644 --- a/lib/logitech/devices/__init__.py +++ b/lib/logitech/devices/__init__.py @@ -33,7 +33,7 @@ def _module(device_name): def default_request_status(devinfo, listener=None): if FEATURE.BATTERY in devinfo.features: if listener: - reply = listener.request(_api.get_device_battery_level, devinfo.number, features=devinfo.features) + reply = listener.call_api(_api.get_device_battery_level, devinfo.number, features=devinfo.features) else: reply = _api.get_device_battery_level(devinfo.handle, devinfo.number, features=devinfo.features) @@ -42,7 +42,7 @@ def default_request_status(devinfo, listener=None): return STATUS.CONNECTED, {PROPS.BATTERY_LEVEL: discharge, PROPS.BATTERY_STATUS: status} if listener: - reply = listener.request(_api.ping, devinfo.number) + reply = listener.call_api(_api.ping, devinfo.number) else: reply = _api.ping(devinfo.handle, devinfo.number) diff --git a/lib/logitech/devices/k750.py b/lib/logitech/devices/k750.py index 7d595ce4..c55b8471 100644 --- a/lib/logitech/devices/k750.py +++ b/lib/logitech/devices/k750.py @@ -36,7 +36,7 @@ def request_status(devinfo, listener=None): if listener is None: reply = _trigger_solar_charge_events(devinfo.handle, devinfo) elif listener: - reply = listener.request(_trigger_solar_charge_events, devinfo) + reply = listener.call_api(_trigger_solar_charge_events, devinfo) else: reply = 0 diff --git a/lib/logitech/unifying_receiver/api.py b/lib/logitech/unifying_receiver/api.py index 34916013..06fb9530 100644 --- a/lib/logitech/unifying_receiver/api.py +++ b/lib/logitech/unifying_receiver/api.py @@ -38,22 +38,22 @@ close = _base.close def get_receiver_info(handle): serial = None - reply = _base.request(handle, 0xff, b'\x83\xB5', b'\x03') + reply = _base.request(handle, 0xFF, b'\x83\xB5', b'\x03') if reply and reply[0:1] == b'\x03': serial = _hexlify(reply[1:5]) firmware = '??.??' - reply = _base.request(handle, 0xff, b'\x81\xF1', b'\x01') + reply = _base.request(handle, 0xFF, b'\x81\xF1', b'\x01') if reply and reply[0:1] == b'\x01': fw_version = _hexlify(reply[1:3]) firmware = fw_version[0:2] + '.' + fw_version[2:4] - reply = _base.request(handle, 0xff, b'\x81\xF1', b'\x02') + reply = _base.request(handle, 0xFF, b'\x81\xF1', b'\x02') if reply and reply[0:1] == b'\x02': firmware += '.B' + _hexlify(reply[1:3]) bootloader = None - reply = _base.request(handle, 0xff, b'\x81\xF1', b'\x04') + reply = _base.request(handle, 0xFF, b'\x81\xF1', b'\x04') if reply and reply[0:1] == b'\x04': bl_version = _hexlify(reply[1:3]) bootloader = bl_version[0:2] + '.' + bl_version[2:4] @@ -61,6 +61,11 @@ def get_receiver_info(handle): return (serial, firmware, bootloader) +def count_devices(handle): + count = _base.request(handle, 0xFF, b'\x80\x02', b'\x02') + return 0 if count is None else ord(count[1:2]) + + def request(handle, devnumber, feature, function=b'\x00', params=b'', features=None): """Makes a feature call to the device, and returns the reply data. diff --git a/lib/logitech/unifying_receiver/listener.py b/lib/logitech/unifying_receiver/listener.py index 5cf7fe10..9f10f34b 100644 --- a/lib/logitech/unifying_receiver/listener.py +++ b/lib/logitech/unifying_receiver/listener.py @@ -42,7 +42,7 @@ class EventsListener(Thread): Incoming packets will be passed to the callback function in sequence, by a separate thread. - While this listener is running, you must use the request() method to make + While this listener is running, you must use the call_api() method to make regular UR API calls; otherwise the expected API replies are most likely to be captured by the listener and delivered to the callback. """ @@ -107,6 +107,7 @@ class EventsListener(Thread): self._task_done.set() _base.close(self._handle) + self._handle = 0 def stop(self): """Tells the listener to stop as soon as possible.""" @@ -120,7 +121,10 @@ class EventsListener(Thread): def handle(self): return self._handle - def request(self, api_function, *args, **kwargs): + def request(self, device, feature_function_index, params=b''): + return self.call_api(_base.request, device, feature_function_index, params) + + def call_api(self, api_function, *args, **kwargs): """Make an UR API request through this listener's receiver. The api_function must have a receiver handle as a first agument, all diff --git a/share/icons/hicolor/128x128/status/battery_000.png b/share/icons/hicolor/128x128/status/battery_000.png index e9d1adf5ce5bc15ed343e375e355974de2326ff1..64dc1b2fb297986f6cbf741b65776ad109ded1f3 100644 GIT binary patch literal 8360 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 bs;}sE@)bG8mEL-hJ_5AW4NztG?O*&K@Pn-u literal 6760 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 Q`6UGC>lkX+YTCv8ADsV~#{d8T diff --git a/share/icons/hicolor/48x48/status/image-missing.png b/share/icons/hicolor/48x48/status/image-missing.png deleted file mode 100644 index 2361f74d4b4d86b5eff81c272b49889b5652b2bd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2502 zcmV;%2|4zOP)sBkJiAEZ_asKa;d1 zdQ8v#A`CcuEMLC<)3}>aIhvvW{4@ll$ZX9JJx9O$zcwJk7pSzOV0J60*N)DH3ERWh z3-BZQQ7G38K!v6sbo$X*Gcha46MSiFKO|l3fkaek%j}u3A$+eS`F=>P*1|cZ7Luzh zn84TC%7jhe3pqRiR4NTY!o;pvk1f7oX>EjrPE$f1UhCZ8_m^OTo2(KH3Vyp($gz7@I zu~?ysZcre+tfUj7i>^bi`ld}x%ox6~u@B-AjuNJ%Q~b`MQL0oJ2vzL^ z5Q*;-;ZGv`@$@zbJgG0{YkT08161rt`m(?~p(jp^lJ1#+*j9*3cs@O(ju zm0c#V4+nMlzA>Ncpr+#v9KWDJ_*OWQ+KTYa;Fr_{J_(H=$JNy7xCs@RP)ulI%J3;T zVS+L)LvSYb_A)op!Rio-W++wa`~!qmVpU`aLeGWeb%6T%_a+9k0}miDO@r{5d~!4R z5`1DKc*Zrrw{kVa6kaiyD4fK^q|=7rX(JvzPs`l~LFu&g?GTI=U`Db8QKt!*O_yx^ zIa_${R8~90mRvJ2(D%cCA>f<<@0Tbf?}hN+ApF-d6?_#_4-sg5#9*R#ToYryNzvvJ z=RCp7al-hg;4e?? zi}2p@4Vb(dzKtdLdN>eM2m7LGVQ)k&oXKgInCKBqn0>P|F+gAaWclqw41PqoIu|z6L%Iy9B#Kl@OAtF_?(VZXF`VaSd#Ajo04$iSe_`zDHaWc*Zw@R|0hujU-`T zaoPQo1v7_9SOsPX?`cCGVFp{+&q=tc^?K7yoTltHr0yYCfxAqA-ydBEUq;r#7vVLq z=S($&cMVa3^T|pG$kZ&!BfYvusEb@kU98Oh=vw$Urv61Zrhd8_Tuxqu zoyRNS!@x4wcC-wFQk$n{fR-mFw~aX%!U~1wUja9nni+9tW*V3gPiBTHfWj*HSWsi< zU6bUC8*d`GN&r90!K3P6FG9PXR>H0m7h%V-a@dZ*q-{q_VcU^X+~f@ygpM5}BC}=U z3eg7HfSb6OZ(?Qwllk&k4<|UPg?~4;4s0f4e)uj z-PT>mCNdF%7o|NoB|b;(CDFK}jTcJ5O85wYcVI1?j#a?D2SaT9iK4oUmo`CfWhw^W zcr||bn;QJoKFl!_T7!vTob{i5hnwysB`iz}f)>DyX+InsG1&tw%Scj(Kz*e!e*~Hh?#K z)UQx;QBR>3y!w|tuRA&``bKfA8NWB;0t&(AZL zpVxlc26*x&@Tg}{i+{Iq=Nn{#u9CtRcO9D~mePLrpa8zvCm%dx>&)O|Z@;|3=}kQ5 zZ|S)F_~s_&SZx4rkBPP6%lb4=Xc6jJ)RGkrTh}-`%Wq3fP_LzFlm3q2Bu|+UK2zDr z;9p$3Wi1}-1v(x-?jnAkr=@k8iGe503@qkp(FtEz{>RPh%uP^5HgjmwCU1hT9Rt7Y z4}WvSW4tIaAg+Scz>_mEU@uvL`KX0Fq2HjMLp_gLwtUSd2WFzyXoAw-Kt8XgkM4L=f`=DA9oQy&wR-W*iUy4SXu?C2}*1!B}i2u6ZH8-G~pMo0{;Y+1pYdM zC&T!j_tAF5-UfB3a=Wzf^Z|O5Hy|H@LJ6-{fnE3i#vi{fKc0a!0`IT?s?_TATWIJx&QzG