539 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			Python
		
	
	
	
			
		
		
	
	
			539 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			Python
		
	
	
	
| # -*- python-mode -*-
 | |
| 
 | |
| ## Copyright (C) 2012-2013  Daniel Pavel
 | |
| ##
 | |
| ## This program is free software; you can redistribute it and/or modify
 | |
| ## it under the terms of the GNU General Public License as published by
 | |
| ## the Free Software Foundation; either version 2 of the License, or
 | |
| ## (at your option) any later version.
 | |
| ##
 | |
| ## This program is distributed in the hope that it will be useful,
 | |
| ## but WITHOUT ANY WARRANTY; without even the implied warranty of
 | |
| ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 | |
| ## GNU General Public License for more details.
 | |
| ##
 | |
| ## You should have received a copy of the GNU General Public License along
 | |
| ## with this program; if not, write to the Free Software Foundation, Inc.,
 | |
| ## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
 | |
| 
 | |
| import os
 | |
| 
 | |
| from logging import DEBUG as _DEBUG
 | |
| from logging import getLogger
 | |
| from time import time as _timestamp
 | |
| 
 | |
| import solaar.gtk as gtk
 | |
| 
 | |
| from gi.repository import GLib, Gtk
 | |
| from gi.repository.Gdk import ScrollDirection
 | |
| from logitech_receiver.status import KEYS as _K
 | |
| from solaar import NAME
 | |
| from solaar.i18n import _
 | |
| 
 | |
| from . import icons as _icons
 | |
| from .about import show_window as _show_about_window
 | |
| from .window import popup as _window_popup
 | |
| from .window import toggle as _window_toggle
 | |
| 
 | |
| _log = getLogger(__name__)
 | |
| del getLogger
 | |
| 
 | |
| #
 | |
| # constants
 | |
| #
 | |
| 
 | |
| _TRAY_ICON_SIZE = 48
 | |
| _MENU_ICON_SIZE = Gtk.IconSize.LARGE_TOOLBAR
 | |
| 
 | |
| #
 | |
| #
 | |
| #
 | |
| 
 | |
| 
 | |
| def _create_menu(quit_handler):
 | |
|     menu = Gtk.Menu()
 | |
| 
 | |
|     # per-device menu entries will be generated as-needed
 | |
| 
 | |
|     no_receiver = Gtk.MenuItem.new_with_label(_('No Logitech device found'))
 | |
|     no_receiver.set_sensitive(False)
 | |
|     menu.append(no_receiver)
 | |
|     menu.append(Gtk.SeparatorMenuItem.new())
 | |
| 
 | |
|     from .action import make
 | |
|     menu.append(make('help-about', _('About %s') % NAME, _show_about_window, stock_id='help-about').create_menu_item())
 | |
|     menu.append(make('application-exit', _('Quit %s') % NAME, quit_handler, stock_id='application-exit').create_menu_item())
 | |
|     del make
 | |
| 
 | |
|     menu.show_all()
 | |
| 
 | |
|     return menu
 | |
| 
 | |
| 
 | |
| _last_scroll = 0
 | |
| 
 | |
| 
 | |
| def _scroll(tray_icon, event, direction=None):
 | |
|     if direction is None:
 | |
|         direction = event.direction
 | |
|         now = event.time / 1000.0
 | |
|     else:
 | |
|         now = None
 | |
| 
 | |
|     if direction != ScrollDirection.UP and direction != ScrollDirection.DOWN:
 | |
|         # ignore all other directions
 | |
|         return
 | |
| 
 | |
|     if sum(map(lambda i: i[1] is not None, _devices_info)) < 2:  # don't bother even trying to scroll if less than two devices
 | |
|         return
 | |
| 
 | |
|     # scroll events come way too fast (at least 5-6 at once)
 | |
|     # so take a little break between them
 | |
|     global _last_scroll
 | |
|     now = now or _timestamp()
 | |
|     if now - _last_scroll < 0.33:  # seconds
 | |
|         return
 | |
|     _last_scroll = now
 | |
| 
 | |
|     # if _log.isEnabledFor(_DEBUG):
 | |
|     #     _log.debug("scroll direction %s", direction)
 | |
| 
 | |
|     global _picked_device
 | |
|     candidate = None
 | |
| 
 | |
|     if _picked_device is None:
 | |
|         for info in _devices_info:
 | |
|             # pick first peripheral found
 | |
|             if info[1] is not None:
 | |
|                 candidate = info
 | |
|                 break
 | |
|     else:
 | |
|         found = False
 | |
|         for info in _devices_info:
 | |
|             if not info[1]:
 | |
|                 # only conside peripherals
 | |
|                 continue
 | |
|             # compare peripherals
 | |
|             if info[0:2] == _picked_device[0:2]:
 | |
|                 if direction == ScrollDirection.UP and candidate:
 | |
|                     # select previous device
 | |
|                     break
 | |
|                 found = True
 | |
|             else:
 | |
|                 if found:
 | |
|                     candidate = info
 | |
|                     if direction == ScrollDirection.DOWN:
 | |
|                         break
 | |
|                     # if direction is up, but no candidate found before _picked,
 | |
|                     # let it run through all candidates, will get stuck with the last one
 | |
|                 else:
 | |
|                     if direction == ScrollDirection.DOWN:
 | |
|                         # only use the first one, in case no candidates are after _picked
 | |
|                         if candidate is None:
 | |
|                             candidate = info
 | |
|                     else:
 | |
|                         candidate = info
 | |
| 
 | |
|         # if the last _picked_device is gone, clear it
 | |
|         # the candidate will be either the first or last one remaining,
 | |
|         # depending on the scroll direction
 | |
|         if not found:
 | |
|             _picked_device = None
 | |
| 
 | |
|     _picked_device = candidate or _picked_device
 | |
|     if _log.isEnabledFor(_DEBUG):
 | |
|         _log.debug('scroll: picked %s', _picked_device)
 | |
|     _update_tray_icon()
 | |
| 
 | |
| 
 | |
| try:
 | |
|     import gi
 | |
|     try:
 | |
|         gi.require_version('AyatanaAppIndicator3', '0.1')
 | |
|         ayatana_appindicator_found = True
 | |
|     except ValueError:
 | |
|         try:
 | |
|             gi.require_version('AppIndicator3', '0.1')
 | |
|             ayatana_appindicator_found = False
 | |
|         except ValueError:
 | |
|             # treat unavailable versions the same as unavailable packages
 | |
|             raise ImportError
 | |
| 
 | |
|     if ayatana_appindicator_found:
 | |
|         from gi.repository import AyatanaAppIndicator3 as AppIndicator3
 | |
|     else:
 | |
|         from gi.repository import AppIndicator3
 | |
| 
 | |
|     if _log.isEnabledFor(_DEBUG):
 | |
|         _log.debug('using %sAppIndicator3' % ('Ayatana ' if ayatana_appindicator_found else ''))
 | |
| 
 | |
|     # Defense against AppIndicator3 bug that treats files in current directory as icon files
 | |
|     # https://bugs.launchpad.net/ubuntu/+source/libappindicator/+bug/1363277
 | |
|     # Defense against bug that shows up in XFCE 4.16 where icons are not upscaled
 | |
|     def _icon_file(icon_name):
 | |
|         if gtk.tray_icon_size is None and not os.path.isfile(icon_name):
 | |
|             return icon_name
 | |
|         icon_info = Gtk.IconTheme.get_default().lookup_icon(
 | |
|             icon_name, gtk.tray_icon_size or _TRAY_ICON_SIZE, Gtk.IconLookupFlags.FORCE_SVG
 | |
|         )
 | |
|         return icon_info.get_filename() if icon_info else icon_name
 | |
| 
 | |
|     def _create(menu):
 | |
|         _icons._init_icon_paths()
 | |
|         theme_paths = Gtk.IconTheme.get_default().get_search_path()
 | |
| 
 | |
|         ind = AppIndicator3.Indicator.new_with_path(
 | |
|             'indicator-solaar', _icon_file(_icons.TRAY_INIT), AppIndicator3.IndicatorCategory.HARDWARE, theme_paths[0]
 | |
|         )
 | |
|         ind.set_title(NAME)
 | |
|         ind.set_status(AppIndicator3.IndicatorStatus.ACTIVE)
 | |
|         # ind.set_attention_icon_full(_icon_file(_icons.TRAY_ATTENTION), '') # works poorly for XFCE 16
 | |
|         # ind.set_label(NAME, NAME)
 | |
| 
 | |
|         ind.set_menu(menu)
 | |
|         ind.connect('scroll-event', _scroll)
 | |
| 
 | |
|         return ind
 | |
| 
 | |
|     def _hide(indicator):
 | |
|         indicator.set_status(AppIndicator3.IndicatorStatus.PASSIVE)
 | |
| 
 | |
|     def _show(indicator):
 | |
|         indicator.set_status(AppIndicator3.IndicatorStatus.ACTIVE)
 | |
| 
 | |
|     def _update_tray_icon():
 | |
|         if _picked_device and gtk.battery_icons_style != 'solaar':
 | |
|             _ignore, _ignore, name, device_status = _picked_device
 | |
|             battery_level = device_status.get(_K.BATTERY_LEVEL)
 | |
|             battery_charging = device_status.get(_K.BATTERY_CHARGING)
 | |
|             tray_icon_name = _icons.battery(battery_level, battery_charging)
 | |
| 
 | |
|             description = '%s: %s' % (name, device_status.to_string())
 | |
|         else:
 | |
|             # there may be a receiver, but no peripherals
 | |
|             tray_icon_name = _icons.TRAY_OKAY if _devices_info else _icons.TRAY_INIT
 | |
| 
 | |
|             description_lines = _generate_description_lines()
 | |
|             description = '\n'.join(description_lines).rstrip('\n')
 | |
| 
 | |
|         # icon_file = _icons.icon_file(icon_name, _TRAY_ICON_SIZE)
 | |
|         _icon.set_icon_full(_icon_file(tray_icon_name), description)
 | |
| 
 | |
|     def _update_menu_icon(image_widget, icon_name):
 | |
|         image_widget.set_from_icon_name(icon_name, _MENU_ICON_SIZE)
 | |
|         # icon_file = _icons.icon_file(icon_name, _MENU_ICON_SIZE)
 | |
|         # image_widget.set_from_file(icon_file)
 | |
|         # image_widget.set_pixel_size(_TRAY_ICON_SIZE)
 | |
| 
 | |
|     def attention(reason=None):
 | |
|         if _icon.get_status() != AppIndicator3.IndicatorStatus.ATTENTION:
 | |
|             # _icon.set_attention_icon_full(_icon_file(_icons.TRAY_ATTENTION), reason or '') # works poorly for XFCe 16
 | |
|             _icon.set_status(AppIndicator3.IndicatorStatus.ATTENTION)
 | |
|             GLib.timeout_add(10 * 1000, _icon.set_status, AppIndicator3.IndicatorStatus.ACTIVE)
 | |
| 
 | |
| except ImportError:
 | |
| 
 | |
|     if _log.isEnabledFor(_DEBUG):
 | |
|         _log.debug('using StatusIcon')
 | |
| 
 | |
|     def _create(menu):
 | |
|         icon = Gtk.StatusIcon.new_from_icon_name(_icons.TRAY_INIT)
 | |
|         icon.set_name(NAME)
 | |
|         icon.set_title(NAME)
 | |
|         icon.set_tooltip_text(NAME)
 | |
|         icon.connect('activate', _window_toggle)
 | |
|         icon.connect('scroll-event', _scroll)
 | |
|         icon.connect('popup-menu', lambda icon, button, time: menu.popup(None, None, icon.position_menu, icon, button, time))
 | |
| 
 | |
|         return icon
 | |
| 
 | |
|     def _hide(icon):
 | |
|         icon.set_visible(False)
 | |
| 
 | |
|     def _show(icon):
 | |
|         icon.set_visible(True)
 | |
| 
 | |
|     def _update_tray_icon():
 | |
|         tooltip_lines = _generate_tooltip_lines()
 | |
|         tooltip = '\n'.join(tooltip_lines).rstrip('\n')
 | |
|         _icon.set_tooltip_markup(tooltip)
 | |
| 
 | |
|         if _picked_device and gtk.battery_icons_style != 'solaar':
 | |
|             _ignore, _ignore, name, device_status = _picked_device
 | |
|             battery_level = device_status.get(_K.BATTERY_LEVEL)
 | |
|             battery_charging = device_status.get(_K.BATTERY_CHARGING)
 | |
|             tray_icon_name = _icons.battery(battery_level, battery_charging)
 | |
|         else:
 | |
|             # there may be a receiver, but no peripherals
 | |
|             tray_icon_name = _icons.TRAY_OKAY if _devices_info else _icons.TRAY_ATTENTION
 | |
|         _icon.set_from_icon_name(tray_icon_name)
 | |
| 
 | |
|     def _update_menu_icon(image_widget, icon_name):
 | |
|         image_widget.set_from_icon_name(icon_name, _MENU_ICON_SIZE)
 | |
| 
 | |
|     _icon_before_attention = None
 | |
| 
 | |
|     def _blink(count):
 | |
|         global _icon_before_attention
 | |
|         if count % 2:
 | |
|             _icon.set_from_icon_name(_icons.TRAY_ATTENTION)
 | |
|         else:
 | |
|             _icon.set_from_icon_name(_icon_before_attention)
 | |
| 
 | |
|         if count > 0:
 | |
|             GLib.timeout_add(1000, _blink, count - 1)
 | |
|         else:
 | |
|             _icon_before_attention = None
 | |
| 
 | |
|     def attention(reason=None):
 | |
|         global _icon_before_attention
 | |
|         if _icon_before_attention is None:
 | |
|             _icon_before_attention = _icon.get_icon_name()
 | |
|             GLib.idle_add(_blink, 9)
 | |
| 
 | |
| 
 | |
| #
 | |
| #
 | |
| #
 | |
| 
 | |
| 
 | |
| def _generate_tooltip_lines():
 | |
|     if not _devices_info:
 | |
|         yield '<b>%s</b>: ' % NAME + _('no receiver')
 | |
|         return
 | |
| 
 | |
|     yield from _generate_description_lines()
 | |
| 
 | |
| 
 | |
| def _generate_description_lines():
 | |
|     if not _devices_info:
 | |
|         yield _('no receiver')
 | |
|         return
 | |
| 
 | |
|     for _ignore, number, name, status in _devices_info:
 | |
|         if number is None:  # receiver
 | |
|             continue
 | |
| 
 | |
|         p = status.to_string()
 | |
|         if p:  # does it have any properties to print?
 | |
|             yield '<b>%s</b>' % name
 | |
|             if status:
 | |
|                 yield '\t%s' % p
 | |
|             else:
 | |
|                 yield '\t%s <small>(' % p + _('offline') + ')</small>'
 | |
|         else:
 | |
|             if status:
 | |
|                 yield '<b>%s</b> <small>(' % name + _('no status') + ')</small>'
 | |
|             else:
 | |
|                 yield '<b>%s</b> <small>(' % name + _('offline') + ')</small>'
 | |
| 
 | |
| 
 | |
| def _pick_device_with_lowest_battery():
 | |
|     if not _devices_info:
 | |
|         return None
 | |
| 
 | |
|     picked = None
 | |
|     picked_level = 1000
 | |
| 
 | |
|     for info in _devices_info:
 | |
|         if info[1] is None:  # is receiver
 | |
|             continue
 | |
|         level = info[-1].get(_K.BATTERY_LEVEL)
 | |
|         # print ("checking %s -> %s", info, level)
 | |
|         if level is not None and picked_level > level:
 | |
|             picked = info
 | |
|             picked_level = level or 0
 | |
| 
 | |
|     if _log.isEnabledFor(_DEBUG):
 | |
|         _log.debug('picked device with lowest battery: %s', picked)
 | |
| 
 | |
|     return picked
 | |
| 
 | |
| 
 | |
| #
 | |
| #
 | |
| #
 | |
| 
 | |
| 
 | |
| def _add_device(device):
 | |
|     assert device
 | |
| 
 | |
|     index = 0
 | |
|     receiver_path = device.receiver.path if device.receiver is not None else device.path
 | |
|     if device.receiver is not None:  # if receiver insert into devices for the receiver in device number order
 | |
|         for idx, (path, _ignore, _ignore, _ignore) in enumerate(_devices_info):
 | |
|             if path and path == receiver_path:
 | |
|                 index = idx + 1  # the first entry matching the receiver serial should be for the receiver itself
 | |
|                 break
 | |
|         while index < len(_devices_info):
 | |
|             path, number, _ignore, _ignore = _devices_info[index]
 | |
|             if not path == receiver_path:
 | |
|                 break
 | |
|             assert number != device.number
 | |
|             if number > device.number:
 | |
|                 break
 | |
|             index = index + 1
 | |
| 
 | |
|     new_device_info = (receiver_path, device.number, device.name, device.status)
 | |
|     _devices_info.insert(index, new_device_info)
 | |
| 
 | |
|     label_prefix = '   '
 | |
|     new_menu_item = Gtk.ImageMenuItem.new_with_label((label_prefix if device.number else '') + device.name)
 | |
|     new_menu_item.set_image(Gtk.Image())
 | |
|     new_menu_item.show_all()
 | |
|     new_menu_item.connect('activate', _window_popup, receiver_path, device.number)
 | |
|     _menu.insert(new_menu_item, index)
 | |
| 
 | |
|     return index
 | |
| 
 | |
| 
 | |
| def _remove_device(index):
 | |
|     assert index is not None
 | |
| 
 | |
|     menu_items = _menu.get_children()
 | |
|     _menu.remove(menu_items[index])
 | |
| 
 | |
|     removed_device = _devices_info.pop(index)
 | |
|     global _picked_device
 | |
|     if _picked_device and _picked_device[0:2] == removed_device[0:2]:
 | |
|         # the current pick was unpaired
 | |
|         _picked_device = None
 | |
| 
 | |
| 
 | |
| def _add_receiver(receiver):
 | |
|     index = len(_devices_info)
 | |
| 
 | |
|     new_receiver_info = (receiver.path, None, receiver.name, None)
 | |
|     _devices_info.insert(index, new_receiver_info)
 | |
| 
 | |
|     new_menu_item = Gtk.ImageMenuItem.new_with_label(receiver.name)
 | |
|     icon_set = _icons.device_icon_set(receiver.name)
 | |
|     new_menu_item.set_image(Gtk.Image().new_from_icon_name(icon_set.names[0], _MENU_ICON_SIZE))
 | |
|     new_menu_item.show_all()
 | |
|     new_menu_item.connect('activate', _window_popup, receiver.path)
 | |
|     _menu.insert(new_menu_item, index)
 | |
| 
 | |
|     return 0
 | |
| 
 | |
| 
 | |
| def _remove_receiver(receiver):
 | |
|     index = 0
 | |
|     # remove all entries in devices_info that match this receiver
 | |
|     while index < len(_devices_info):
 | |
|         path, _ignore, _ignore, _ignore = _devices_info[index]
 | |
|         if path == receiver.path:
 | |
|             _remove_device(index)
 | |
|         else:
 | |
|             index += 1
 | |
| 
 | |
| 
 | |
| def _update_menu_item(index, device):
 | |
|     if device is None or device.status is None:
 | |
|         _log.warn('updating an inactive device %s, assuming disconnected', device)
 | |
|         return None
 | |
| 
 | |
|     menu_items = _menu.get_children()
 | |
|     menu_item = menu_items[index]
 | |
| 
 | |
|     level = device.status.get(_K.BATTERY_LEVEL)
 | |
|     charging = device.status.get(_K.BATTERY_CHARGING)
 | |
|     icon_name = _icons.battery(level, charging)
 | |
| 
 | |
|     menu_item.set_label(('  ' if 0 < device.number <= 6 else '') + device.name + ': ' + device.status.to_string())
 | |
|     image_widget = menu_item.get_image()
 | |
|     image_widget.set_sensitive(bool(device.online))
 | |
|     _update_menu_icon(image_widget, icon_name)
 | |
| 
 | |
| 
 | |
| #
 | |
| #
 | |
| #
 | |
| 
 | |
| # for which device to show the battery info in systray, if more than one
 | |
| # it's actually an entry in _devices_info
 | |
| _picked_device = None
 | |
| 
 | |
| # cached list of devices and some of their properties
 | |
| # contains tuples of (receiver path, device number, name, status)
 | |
| _devices_info = []
 | |
| 
 | |
| _menu = None
 | |
| _icon = None
 | |
| 
 | |
| 
 | |
| def init(_quit_handler):
 | |
|     global _menu, _icon
 | |
|     assert _menu is None
 | |
|     _menu = _create_menu(_quit_handler)
 | |
|     assert _icon is None
 | |
|     _icon = _create(_menu)
 | |
|     update()
 | |
| 
 | |
| 
 | |
| def destroy():
 | |
|     global _icon, _menu, _devices_info
 | |
|     if _icon is not None:
 | |
|         i, _icon = _icon, None
 | |
|         _hide(i)
 | |
|         i = None
 | |
| 
 | |
|     _icon = None
 | |
|     _menu = None
 | |
|     _devices_info = None
 | |
| 
 | |
| 
 | |
| def update(device=None):
 | |
|     if _icon is None:
 | |
|         return
 | |
| 
 | |
|     if device is not None:
 | |
|         if device.kind is None:
 | |
|             # receiver
 | |
|             is_alive = bool(device)
 | |
|             receiver_path = device.path
 | |
|             if is_alive:
 | |
|                 index = None
 | |
|                 for idx, (path, _ignore, _ignore, _ignore) in enumerate(_devices_info):
 | |
|                     if path == receiver_path:
 | |
|                         index = idx
 | |
|                         break
 | |
| 
 | |
|                 if index is None:
 | |
|                     _add_receiver(device)
 | |
|             else:
 | |
|                 _remove_receiver(device)
 | |
| 
 | |
|         else:
 | |
|             # peripheral
 | |
|             is_paired = bool(device)
 | |
|             receiver_path = device.receiver.path if device.receiver is not None else device.path
 | |
|             index = None
 | |
|             for idx, (path, number, _ignore, _ignore) in enumerate(_devices_info):
 | |
|                 if path == receiver_path and number == device.number:
 | |
|                     index = idx
 | |
| 
 | |
|             if is_paired:
 | |
|                 if index is None:
 | |
|                     index = _add_device(device)
 | |
|                 _update_menu_item(index, device)
 | |
|             else:  # was just unpaired or unplugged
 | |
|                 if index is not None:
 | |
|                     _remove_device(index)
 | |
| 
 | |
|         menu_items = _menu.get_children()
 | |
|         no_receivers_index = len(_devices_info)
 | |
|         menu_items[no_receivers_index].set_visible(not _devices_info)
 | |
| 
 | |
|     global _picked_device
 | |
|     if (not _picked_device or _last_scroll == 0) and device is not None and device.kind is not None:
 | |
|         # if it's just a receiver update, it's unlikely the picked device would change
 | |
|         _picked_device = _pick_device_with_lowest_battery()
 | |
| 
 | |
|     _update_tray_icon()
 | |
| 
 | |
|     if _icon:
 | |
|         if not _devices_info:
 | |
|             _hide(_icon)
 | |
|         else:
 | |
|             _show(_icon)
 |