496 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Python
		
	
	
	
			
		
		
	
	
			496 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Python
		
	
	
	
| #
 | |
| #
 | |
| #
 | |
| 
 | |
| from __future__ import absolute_import, division, print_function, unicode_literals
 | |
| 
 | |
| from logging import getLogger, DEBUG as _DEBUG
 | |
| _log = getLogger(__name__)
 | |
| del getLogger
 | |
| 
 | |
| from time import time as _timestamp
 | |
| 
 | |
| from gi.repository import Gtk, GLib
 | |
| from gi.repository.Gdk import ScrollDirection
 | |
| 
 | |
| from solaar import NAME
 | |
| from logitech.unifying_receiver.status import KEYS as _K
 | |
| from . import icons as _icons
 | |
| from .window import popup as _window_popup, toggle as _window_toggle
 | |
| 
 | |
| #
 | |
| # constants
 | |
| #
 | |
| 
 | |
| _TRAY_ICON_SIZE = 32 #  pixels
 | |
| _MENU_ICON_SIZE = Gtk.IconSize.LARGE_TOOLBAR
 | |
| _RECEIVER_SEPARATOR = ('~', None, None, None)
 | |
| 
 | |
| #
 | |
| #
 | |
| #
 | |
| 
 | |
| def _create_menu():
 | |
| 	menu = Gtk.Menu()
 | |
| 
 | |
| 	# per-device menu entries will be generated as-needed
 | |
| 
 | |
| 	no_receiver = Gtk.MenuItem.new_with_label('No receiver found')
 | |
| 	no_receiver.set_sensitive(False)
 | |
| 	menu.append(no_receiver)
 | |
| 	menu.append(Gtk.SeparatorMenuItem.new())
 | |
| 
 | |
| 	from .action import about, make
 | |
| 	menu.append(about.create_menu_item())
 | |
| 	menu.append(make('application-exit', 'Quit', Gtk.main_quit).create_menu_item())
 | |
| 	del about, make
 | |
| 
 | |
| 	menu.show_all()
 | |
| 
 | |
| 	return menu
 | |
| 
 | |
| 
 | |
| try:
 | |
| 	# raise ImportError
 | |
| 	from gi.repository import AppIndicator3
 | |
| 
 | |
| 	_log.info("using AppIndicator3")
 | |
| 
 | |
| 
 | |
| 	_last_scroll = 0
 | |
| 	def _scroll(ind, _, direction):
 | |
| 		if direction != ScrollDirection.UP and direction != ScrollDirection.DOWN:
 | |
| 			# ignore all other directions
 | |
| 			return
 | |
| 
 | |
| 		if len(_devices_info) < 4:
 | |
| 			# don't bother with scrolling when there's only one receiver
 | |
| 			# with only one device (3 = [receiver, device, separator])
 | |
| 			return
 | |
| 
 | |
| 		# scroll events come way too fast (at least 5-6 at once)
 | |
| 		# so take a little break between them
 | |
| 		global _last_scroll
 | |
| 		now = _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()
 | |
| 
 | |
| 
 | |
| 	def _create(menu):
 | |
| 		theme_paths = Gtk.IconTheme.get_default().get_search_path()
 | |
| 
 | |
| 		ind = AppIndicator3.Indicator.new_with_path(
 | |
| 						'indicator-solaar',
 | |
| 						_icons.TRAY_INIT,
 | |
| 						AppIndicator3.IndicatorCategory.HARDWARE,
 | |
| 						':'.join(theme_paths))
 | |
| 		ind.set_title(NAME)
 | |
| 		ind.set_status(AppIndicator3.IndicatorStatus.ACTIVE)
 | |
| 		ind.set_attention_icon_full(_icons.TRAY_ATTENTION, '')
 | |
| 		# ind.set_label(NAME, NAME)
 | |
| 
 | |
| 		ind.set_menu(menu)
 | |
| 		ind.connect('scroll-event', _scroll)
 | |
| 
 | |
| 		return ind
 | |
| 
 | |
| 
 | |
| 	def _destroy(indicator):
 | |
| 		indicator.set_status(AppIndicator3.IndicatorStatus.PASSIVE)
 | |
| 
 | |
| 
 | |
| 	def _update_tray_icon():
 | |
| 		if _picked_device:
 | |
| 			_, _, 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)
 | |
| 		else:
 | |
| 			# there may be a receiver, but no peripherals
 | |
| 			tray_icon_name = _icons.TRAY_OKAY if _devices_info else _icons.TRAY_INIT
 | |
| 
 | |
| 			tooltip_lines = _generate_tooltip_lines()
 | |
| 			description = '\n'.join(tooltip_lines).rstrip('\n')
 | |
| 
 | |
| 		# icon_file = _icons.icon_file(icon_name, _TRAY_ICON_SIZE)
 | |
| 		_icon.set_icon_full(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(_icons.TRAY_ATTENTION, reason or '')
 | |
| 			_icon.set_status(AppIndicator3.IndicatorStatus.ATTENTION)
 | |
| 			GLib.timeout_add(10 * 1000, _icon.set_status, AppIndicator3.IndicatorStatus.ACTIVE)
 | |
| 
 | |
| except ImportError:
 | |
| 
 | |
| 	_log.info("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('popup_menu',
 | |
| 						lambda icon, button, time:
 | |
| 							menu.popup(None, None, icon.position_menu, icon, button, time))
 | |
| 
 | |
| 		return icon
 | |
| 
 | |
| 
 | |
| 	def _destroy(icon):
 | |
| 		icon.set_visible(False)
 | |
| 
 | |
| 
 | |
| 	def _update_tray_icon():
 | |
| 		tooltip_lines = _generate_tooltip_lines()
 | |
| 		tooltip = '\n'.join(tooltip_lines).rstrip('\n')
 | |
| 		_icon.set_tooltip_markup(tooltip)
 | |
| 
 | |
| 		if _picked_device:
 | |
| 			_, _, 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)
 | |
| 
 | |
| 	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>: no receivers' % NAME
 | |
| 		return
 | |
| 
 | |
| 	yield '<b>%s</b>' % NAME
 | |
| 	yield ''
 | |
| 
 | |
| 	for _, number, name, status in _devices_info:
 | |
| 		if number is None:  # receiver
 | |
| 			continue
 | |
| 
 | |
| 		p = str(status)
 | |
| 		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>(offline)</small>' % p
 | |
| 		else:
 | |
| 			if status:
 | |
| 				yield '<b>%s</b> <small>no status</small>' % name
 | |
| 			else:
 | |
| 				yield '<b>%s</b> <small>(offline)</small>' % name
 | |
| 		yield ''
 | |
| 
 | |
| 
 | |
| 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/separator
 | |
| 			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
 | |
| 	assert device.receiver
 | |
| 	receiver_path = device.receiver.path
 | |
| 	assert receiver_path
 | |
| 
 | |
| 	index = None
 | |
| 	for idx, (path, _, _, _) in enumerate(_devices_info):
 | |
| 		if path == receiver_path:
 | |
| 			# the first entry matching the receiver serial should be for the receiver itself
 | |
| 			index = idx + 1
 | |
| 			break
 | |
| 	assert index is not None
 | |
| 
 | |
| 	# proper ordering (according to device.number) for a receiver's devices
 | |
| 	while True:
 | |
| 		path, number, _, _ = _devices_info[index]
 | |
| 		if path == _RECEIVER_SEPARATOR[0]:
 | |
| 			break
 | |
| 		assert path == receiver_path
 | |
| 		assert number != device.number
 | |
| 		if number > device.number:
 | |
| 			break
 | |
| 		index = index + 1
 | |
| 
 | |
| 	new_device_info = (receiver_path, device.number, device.name, device.status)
 | |
| 	assert len(new_device_info) == len(_RECEIVER_SEPARATOR)
 | |
| 	_devices_info.insert(index, new_device_info)
 | |
| 
 | |
| 	# label_prefix = b'\xE2\x94\x84 '.decode('utf-8')
 | |
| 	label_prefix = '   '
 | |
| 
 | |
| 	new_menu_item = Gtk.ImageMenuItem.new_with_label(label_prefix + device.name)
 | |
| 	new_menu_item.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)
 | |
| 	assert len(new_receiver_info) == len(_RECEIVER_SEPARATOR)
 | |
| 	_devices_info.append(new_receiver_info)
 | |
| 
 | |
| 	new_menu_item = Gtk.ImageMenuItem.new_with_label(receiver.name)
 | |
| 	_menu.insert(new_menu_item, index)
 | |
| 	icon_set = _icons.device_icon_set(receiver.name)
 | |
| 	new_menu_item.set_image(Gtk.Image().new_from_icon_set(icon_set, _MENU_ICON_SIZE))
 | |
| 	new_menu_item.show_all()
 | |
| 	new_menu_item.connect('activate', _window_popup, receiver.path)
 | |
| 
 | |
| 	_devices_info.append(_RECEIVER_SEPARATOR)
 | |
| 	separator = Gtk.SeparatorMenuItem.new()
 | |
| 	separator.set_visible(True)
 | |
| 	_menu.insert(separator, index + 1)
 | |
| 
 | |
| 	return 0
 | |
| 
 | |
| 
 | |
| def _remove_receiver(receiver):
 | |
| 	index = 0
 | |
| 	found = False
 | |
| 
 | |
| 	# remove all entries in devices_info that match this receiver
 | |
| 	while index < len(_devices_info):
 | |
| 		path, _, _, _ = _devices_info[index]
 | |
| 		if path == receiver.path:
 | |
| 			found = True
 | |
| 			_remove_device(index)
 | |
| 		elif found and path == _RECEIVER_SEPARATOR[0]:
 | |
| 			# the separator after this receiver
 | |
| 			_remove_device(index)
 | |
| 			break
 | |
| 		else:
 | |
| 			index += 1
 | |
| 
 | |
| 
 | |
| def _update_menu_item(index, device):
 | |
| 	assert device
 | |
| 	assert device.status is not 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)
 | |
| 
 | |
| 	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():
 | |
| 	global _menu, _icon
 | |
| 	assert _menu is None
 | |
| 	_menu = _create_menu()
 | |
| 	assert _icon is None
 | |
| 	_icon = _create(_menu)
 | |
| 
 | |
| 
 | |
| def destroy():
 | |
| 	global _icon, _menu, _devices_info
 | |
| 	assert _icon is not None
 | |
| 	i, _icon = _icon, None
 | |
| 	_destroy(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, _, _, _) 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
 | |
| 			index = None
 | |
| 			for idx, (path, number, _, _) 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
 | |
| 				if index:
 | |
| 					_remove_device(index)
 | |
| 
 | |
| 		menu_items = _menu.get_children()
 | |
| 		no_receivers_index = len(_devices_info)
 | |
| 		menu_items[no_receivers_index].set_visible(not _devices_info)
 | |
| 		menu_items[no_receivers_index + 1].set_visible(not _devices_info)
 | |
| 
 | |
| 	global _picked_device
 | |
| 	if not _picked_device 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()
 |