added a devices status window

This commit is contained in:
Daniel Pavel 2012-10-04 01:36:24 +03:00
parent 4d63c960e5
commit 9ba6aa1c31
41 changed files with 891 additions and 500 deletions

59
app/__init__.py Normal file
View File

@ -0,0 +1,59 @@
#
#
#
import threading
from gi.repository import Gtk
from gi.repository import GObject
from . import constants as C
from .watcher import WatcherThread
from . import ui
def _status_updated(watcher, icon, window):
while True:
watcher.status_changed.wait()
text = watcher.status_text
watcher.status_changed.clear()
if icon:
GObject.idle_add(icon.set_tooltip_text, text)
if window:
ur_detected = watcher.has_receiver()
devices = [ watcher.devices[k] for k in watcher.devices ] if ur_detected else []
GObject.idle_add(ui.window.update, window, ur_detected, devices)
# def _pair_new_device(trigger, watcher):
# pass
def run(images_path):
GObject.threads_init()
ui.init(images_path)
ui.notify.start(C.APP_TITLE, ui.image)
watcher = WatcherThread(ui.notify.show)
watcher.start()
window = ui.window.create(C.APP_TITLE, ui.image)
menu_actions = [('Scan all devices', watcher.request_all_statuses),
# ('Pair new device', _pair_new_device, watcher),
None,
('Quit', Gtk.main_quit)]
click_action = (ui.window.toggle, window) if window else None
tray_icon = ui.icon.create(ui.image('icon'), C.APP_TITLE, menu_actions, click_action)
ui_update_thread = threading.Thread(target=_status_updated, name='ui_update', args=(watcher, tray_icon, window))
ui_update_thread.daemon = True
ui_update_thread.start()
Gtk.main()
watcher.stop()
ui.notify.stop()

10
app/constants.py Normal file
View File

@ -0,0 +1,10 @@
#
# Commonly used strings
#
APP_TITLE = 'Solaar'
UNIFYING_RECEIVER = 'Unifying Receiver'
NO_DEVICES = 'No devices attached.'
SCANNING = 'Initializing...'
NO_RECEIVER = 'Unifying Receiver not found.'
FOUND_RECEIVER = 'Unifying Receiver found.'

26
app/ui/__init__.py Normal file
View File

@ -0,0 +1,26 @@
# pass
import os.path as _os_path
from . import (icon, notify, window)
_images_path = None
_IMAGES = {}
def init(images_path=None):
global _images_path
_images_path = images_path
def image(name):
if name in _IMAGES:
return _IMAGES[name]
if _images_path:
path = _os_path.join(_images_path, name + '.png')
if _os_path.isfile(path):
_IMAGES[name] = path
return path
else:
_IMAGES[name] = None

42
app/ui/icon.py Normal file
View File

@ -0,0 +1,42 @@
#
#
#
from gi.repository import Gtk
def _show_icon_menu(icon, button, time, menu):
menu.popup(None, None, icon.position_menu, icon, button, time)
def create(app_icon, title, menu_actions, click_action=None):
icon = Gtk.StatusIcon.new_from_file(app_icon)
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)
if menu_actions:
if type(menu_actions) == list:
menu = Gtk.Menu()
for action in menu_actions:
if action:
item = Gtk.MenuItem(action[0])
function = action[1]
args = action[2:] if len(action) > 2 else ()
item.connect('activate', function, *args)
menu.append(item)
else:
menu.append(Gtk.SeparatorMenuItem())
menu.show_all()
icon.connect('popup_menu', _show_icon_menu, menu)
else:
icon.connect('popup_menu', menu_actions)
return icon

56
app/ui/notify.py Normal file
View File

@ -0,0 +1,56 @@
#
# Optional desktop notifications.
#
try:
import notify2 as _notify
available = True
_app_title = None
_images = lambda x: None
_notifications = {}
def start(app_title, images=None):
global _app_title, _images
_notify.init(app_title)
_app_title = app_title
_images = images or (lambda x: None)
def stop():
global _app_title
_app_title = None
all(n.close() for n in list(_notifications.values()))
_notify.uninit()
_notifications.clear()
def show(status, title, text, icon=None):
if not _app_title:
return
if title in _notifications:
notification = _notifications[title]
else:
_notifications[title] = notification = _notify.Notification(title)
if text == notification.message:
# there's no need to show the same notification twice in a row
return
path = _images('devices/' + title if icon is None else icon)
icon = ('error' if status < 0 else 'info') if path is None else path
notification.update(title, text, icon)
notification.show()
except ImportError:
import logging
logging.exception("ouch")
logging.warn("python-notify2 not found, desktop notifications are disabled")
available = False
def start(app_title): pass
def stop(): pass
def show(status, title, text, icon=None): pass

213
app/ui/window.py Normal file
View File

@ -0,0 +1,213 @@
#
#
#
from gi.repository import Gtk
from gi.repository import Gdk
from gi.repository import GdkPixbuf
from .. import constants as C
_DEVICE_ICON_SIZE = 48
_STATUS_ICON_SIZE = 64
_PLACEHOLDER = '~'
_images = None
_MAX_DEVICES = 7
_ICONS = {}
def _icon(icon, title, size=_DEVICE_ICON_SIZE, fallback=None):
icon = icon or Gtk.Image()
if title and title in _ICONS:
icon.set_from_pixbuf(_ICONS[title])
else:
icon_file = _images(title) if title else None
if icon_file:
pixbuf = GdkPixbuf.Pixbuf().new_from_file(icon_file)
if pixbuf.get_width() > size or pixbuf.get_height() > size:
if pixbuf.get_width() > pixbuf.get_height():
new_width = size
new_height = size * pixbuf.get_height() / pixbuf.get_width()
else:
new_width = size * pixbuf.get_width() / pixbuf.get_height()
new_height = size
pixbuf = pixbuf.scale_simple(new_width, new_height, GdkPixbuf.InterpType.HYPER)
icon.set_from_pixbuf(pixbuf)
_ICONS[title] = pixbuf
elif fallback:
icon.set_from_icon_name(fallback, size if size < _DEVICE_ICON_SIZE else Gtk.IconSize.DIALOG)
if size >= _DEVICE_ICON_SIZE:
icon.set_size_request(size, size)
return icon
def update(window, ur_available, devices):
if not window or not window.get_child():
return
controls = list(window.get_child().get_children())
first = controls[0]
first.set_visible(not ur_available or not devices)
if ur_available:
ur_status = C.FOUND_RECEIVER if devices else C.NO_DEVICES
else:
ur_status = C.NO_RECEIVER
_, label = first.get_children()
label.set_markup('<big><b>%s</b></big>\n%s' % (C.UNIFYING_RECEIVER, ur_status))
for index in range(1, _MAX_DEVICES):
box = controls[index]
devstatus = [d for d in devices if d.number == index]
devstatus = devstatus[0] if devstatus else None
box.set_visible(devstatus is not None)
if devstatus:
box.set_sensitive(devstatus.code >= 0)
icon, expander = box.get_children()
if not expander.get_data('devstatus'):
expander.set_data('devstatus', devstatus,)
_icon(icon, 'devices/' + devstatus.name, fallback=devstatus.type.lower())
label = expander.get_label_widget()
if expander.get_expanded():
label.set_markup('<big><b>%s</b></big>' % devstatus.name)
else:
label.set_markup('<big><b>%s</b></big>\n%s' % (devstatus.name, devstatus.props['text']))
ebox = expander.get_child()
# refresh_button = ebox.get_children()[0]
# refresh_button.connect('activate', devstatus.refresh)
texts = []
battery_icon = ebox.get_children()[-1]
if 'battery-level' in devstatus.props:
level = devstatus.props['battery-level']
icon_name = 'battery/' + str((level + 10) // 20)
_icon(battery_icon, icon_name, _STATUS_ICON_SIZE)
texts.append('Battery: ' + str(level) + '%')
else:
_icon(battery_icon, 'battery/unknown', _STATUS_ICON_SIZE)
texts.append('Battery: unknown')
battery_icon.set_tooltip_text(texts[-1])
light_icon = ebox.get_children()[-2]
if 'light-level' in devstatus.props:
lux = devstatus.props['light-level']
icon_name = 'light/' + str((lux + 50) // 100)
_icon(light_icon, icon_name, _STATUS_ICON_SIZE)
texts.append('Light: ' + str(lux) + ' lux')
light_icon.set_tooltip_text(texts[-1])
light_icon.set_visible(True)
else:
light_icon.set_visible(False)
label = ebox.get_children()[-3]
label.set_text('\n'.join(texts))
def _expander_activate(expander):
devstatus = expander.get_data('devstatus')
if devstatus:
label = expander.get_label_widget()
if expander.get_expanded():
label.set_markup('<big><b>%s</b></big>\n%s' % (devstatus.name, devstatus.props['text']))
else:
label.set_markup('<big><b>%s</b></big>' % devstatus.name)
def _device_box(title):
icon = _icon(None, 'devices/' + title)
icon.set_alignment(0.5, 0)
label = Gtk.Label()
label.set_markup('<big><b>%s</b></big>' % title)
label.set_alignment(0, 0.5)
label.set_can_focus(False)
box = Gtk.HBox(spacing=10)
box.pack_start(icon, False, False, 0)
if title == C.UNIFYING_RECEIVER:
box.add(label)
else:
expander = Gtk.Expander()
expander.set_can_focus(False)
expander.set_label_widget(label)
expander.connect('activate', _expander_activate)
ebox = Gtk.HBox(False, 10)
ebox.set_border_width(4)
# refresh_button = Gtk.Button()
# refresh_button.set_image(_icon(None, None, size=Gtk.IconSize.SMALL_TOOLBAR, fallback='reload'))
# refresh_button.set_focus_on_click(False)
# refresh_button.set_can_focus(False)
# refresh_button.set_image_position(Gtk.PositionType.TOP)
# refresh_button.set_alignment(0.5, 0.5)
# refresh_button.set_relief(Gtk.ReliefStyle.NONE)
# refresh_button.set_size_request(20, 20)
# refresh_button.set_tooltip_text('Refresh')
# ebox.pack_start(refresh_button, False, False, 2)
label = Gtk.Label()
label.set_alignment(0, 0.5)
ebox.pack_start(label, False, True, 8)
light_icon = _icon(None, 'light/unknown', _STATUS_ICON_SIZE)
ebox.pack_end(light_icon, False, True, 0)
battery_icon = _icon(None, 'battery/unknown', _STATUS_ICON_SIZE)
ebox.pack_end(battery_icon, False, True, 0)
expander.add(ebox)
box.pack_start(expander, True, True, 1)
box.show_all()
box.set_visible(title != _PLACEHOLDER)
return box
def create(title, images=None):
global _images
_images = images or (lambda x: None)
vbox = Gtk.VBox(spacing=8)
vbox.set_border_width(6)
vbox.add(_device_box(C.UNIFYING_RECEIVER))
for i in range(1, _MAX_DEVICES):
vbox.add(_device_box(_PLACEHOLDER))
vbox.set_visible(True)
window = Gtk.Window() # Gtk.WindowType.POPUP)
window.set_title(title)
window.set_icon_from_file(_images('icon'))
window.set_keep_above(True)
window.set_decorated(False)
window.set_skip_taskbar_hint(True)
window.set_skip_pager_hint(True)
window.set_deletable(False)
window.set_resizable(False)
window.set_position(Gtk.WindowPosition.MOUSE)
window.set_type_hint(Gdk.WindowTypeHint.TOOLTIP)
window.connect('focus-out-event', _hide)
window.add(vbox)
return window
def _hide(window, _):
window.set_visible(False)
def toggle(_, window):
if window.get_visible():
window.set_visible(False)
else:
window.present()

200
app/watcher.py Normal file
View File

@ -0,0 +1,200 @@
#
#
#
import logging
import threading
import time
import constants as C
from logitech.unifying_receiver import api
from logitech.unifying_receiver.listener import EventsListener
from logitech import devices
_STATUS_TIMEOUT = 97 # seconds
_THREAD_SLEEP = 7 # seconds
_FORGET_TIMEOUT = 5 * 60 # seconds
class _DevStatus(api.AttachedDeviceInfo):
timestamp = time.time()
code = devices.STATUS.CONNECTED
props = {devices.PROPS.TEXT: devices.STATUS_NAME[devices.STATUS.CONNECTED]}
refresh = None
class WatcherThread(threading.Thread):
def __init__(self, notify_callback=None):
super(WatcherThread, self).__init__(name='WatcherThread')
self.daemon = True
self.active = False
self.notify = notify_callback
self.status_text = None
self.status_changed = threading.Event()
self.listener = None
self.devices = {}
def run(self):
self.active = True
self._notify(0, C.UNIFYING_RECEIVER, C.SCANNING)
while self.active:
if self.listener is None:
receiver = api.open()
if receiver:
self._notify(1, C.UNIFYING_RECEIVER, C.FOUND_RECEIVER)
for devinfo in api.list_devices(receiver):
devstatus = _DevStatus(*devinfo)
self.devices[devinfo.number] = devstatus
self._notify(devices.STATUS.CONNECTED, devstatus.name, devices.STATUS_NAME[devices.STATUS.CONNECTED])
self.listener = EventsListener(receiver, self._events_callback)
self.listener.start()
self._update_status()
else:
self._notify(-1, C.UNIFYING_RECEIVER, C.NO_RECEIVER)
elif not self.listener.active:
self.listener = None
self._notify(-1, C.UNIFYING_RECEIVER, C.NO_RECEIVER)
self.devices.clear()
if self.active:
update_icon = True
if self.listener and self.devices:
update_icon &= self._check_old_statuses()
if self.active:
if update_icon:
self._update_status()
time.sleep(_THREAD_SLEEP)
def stop(self):
self.active = False
if self.listener:
self.listener.stop()
api.close(self.listener.receiver)
def has_receiver(self):
return self.listener is not None and self.listener.active
def request_all_statuses(self, _=None):
updated = False
for d in range(1, 7):
devstatus = self.devices.get(d)
if devstatus:
status = devices.request_status(devstatus, self.listener)
updated |= self._device_status_changed(devstatus, status)
else:
devstatus = self._new_device(d)
updated |= devstatus is not None
if updated:
self._update_status()
def _check_old_statuses(self):
updated = False
for devstatus in list(self.devices.values()):
if time.time() - devstatus.timestamp > _STATUS_TIMEOUT:
status = devices.ping(devstatus, self.listener)
updated |= self._device_status_changed(devstatus, status)
return updated
def _new_device(self, device):
devinfo = api.get_device_info(self.listener.receiver, device)
if devinfo:
devstatus = _DevStatus(*devinfo)
self.devices[device] = devstatus
self._notify(devstatus.code, devstatus.name, devstatus.props[devices.PROPS.TEXT])
return devinfo
def _events_callback(self, code, device, data):
logging.debug("%s: event %02x %d %s", time.asctime(), code, device, repr(data))
updated = False
if device in self.devices:
devstatus = self.devices[device]
if code == 0x10 and data[0] == 'b\x8F':
updated = True
self._device_status_changed(devstatus, devices.STATUS.UNAVAILABLE)
elif code == 0x11:
status = devices.process_event(devstatus, self.listener, data)
updated |= self._device_status_changed(devstatus, status)
else:
logging.warn("unknown event code %02x", code)
elif device:
logging.debug("got event (%d, %d, %s) for new device", code, device, repr(data))
self._new_device(device)
updated = True
else:
logging.warn("don't know how to handle event (%d, %d, %s)", code, device, data)
if updated:
self._update_status()
def _device_status_changed(self, devstatus, status):
if status is None:
return False
old_status_code = devstatus.code
devstatus.timestamp = time.time()
if type(status) == int:
devstatus.code = status
if devstatus.code in devices.STATUS_NAME:
devstatus.props[devices.PROPS.TEXT] = devices.STATUS_NAME[devstatus.code]
else:
devstatus.code = status[0]
devstatus.props.update(status[1])
if old_status_code != devstatus.code:
logging.debug("%s: device status changed %s => %s: %s", time.asctime(), old_status_code, devstatus.code, devstatus.props)
# if not (devstatus.code == 0 and old_status_code > 0):
self._notify(devstatus.code, devstatus.name, devstatus.props[devices.PROPS.TEXT])
return True
def _notify(self, *args):
if self.notify:
self.notify(*args)
def notify_full(self):
if self.listener and self.listener.active:
if self.devices:
for devstatus in self.devices.values():
self._notify(0, devstatus.name, devstatus.props[devices.PROPS.TEXT])
else:
self._notify(0, C.UNIFYING_RECEIVER, C.NO_DEVICES)
else:
self._notify(-1, C.UNIFYING_RECEIVER, C.NO_RECEIVER)
def _update_status(self):
last_status_text = self.status_text
if self.listener and self.listener.active:
if self.devices:
all_statuses = []
for d in self.devices:
devstatus = self.devices[d]
status_text = devstatus.props[devices.PROPS.TEXT]
if status_text:
if ' ' in status_text:
all_statuses.append(devstatus.name)
all_statuses.append(' ' + status_text)
else:
all_statuses.append(devstatus.name + ' ' + status_text)
else:
all_statuses.append(devstatus.name)
self.status_text = '\n'.join(all_statuses)
else:
self.status_text = C.NO_DEVICES
else:
self.status_text = C.NO_RECEIVER
if self.status_text != last_status_text:
self.status_changed.set()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

BIN
images/battery/0.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
images/battery/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
images/battery/2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
images/battery/3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
images/battery/4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
images/battery/5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
images/battery/unknown.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
images/light/0.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

BIN
images/light/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
images/light/2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
images/light/3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
images/light/4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
images/light/5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
images/light/unknown.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -24,7 +24,7 @@ def scan_devices(receiver):
for index in range(0, len(devinfo.features)): for index in range(0, len(devinfo.features)):
feature = devinfo.features[index] feature = devinfo.features[index]
if feature: if feature:
print "~ Feature %s (%s) at index %d" % (FEATURE_NAME[feature], hexlify(feature), index) print " ~ Feature %s (%s) at index %d" % (FEATURE_NAME[feature], hexlify(feature), index)
if FEATURE.BATTERY in devinfo.features: if FEATURE.BATTERY in devinfo.features:
discharge, dischargeNext, status = api.get_device_battery_level(receiver, devinfo.number, features_array=devinfo.features) discharge, dischargeNext, status = api.get_device_battery_level(receiver, devinfo.number, features_array=devinfo.features)
@ -35,19 +35,8 @@ def scan_devices(receiver):
if keys is not None and keys: if keys is not None and keys:
print " %d reprogrammable keys found" % len(keys) print " %d reprogrammable keys found" % len(keys)
for k in keys: for k in keys:
flags = '' flags = ','.join(KEY_FLAG_NAME[f] for f in KEY_FLAG_NAME if k.flags & f)
if k.flags & KEY_FLAG.REPROGRAMMABLE: print " %2d: %-12s => %-12s :%s" % (k.index, KEY_NAME[k.id], KEY_NAME[k.task], flags)
flags += ' reprogrammable'
if k.flags & KEY_FLAG.FN_SENSITIVE:
flags += ' fn-sensitive'
if k.flags & KEY_FLAG.NONSTANDARD:
flags += ' nonstandard'
if k.flags & KEY_FLAG.IS_FN:
flags += ' is-fn'
if k.flags & KEY_FLAG.MSE:
flags += ' mse'
print " %2d: %s => %s :%s" % (k.index, KEY_NAME[k.id], KEY_NAME[k.task], flags)
print "--------" print "--------"

View File

@ -5,20 +5,55 @@
from . import k750 from . import k750
from .constants import * from .constants import *
from ..unifying_receiver import api as _api
from ..unifying_receiver.common import FallbackDict as _FDict
_REQUEST_STATUS_FUNCTIONS = {
k750.NAME : k750.request_status, def ping(devinfo, listener):
} reply = listener.request(_api.ping, devinfo.number)
return STATUS.CONNECTED if reply else STATUS.UNAVAILABLE
def default_request_status(devinfo, listener):
if _api.C.FEATURE.BATTERY in devinfo.features:
reply = listener.request(_api.get_device_battery_level, devinfo.number, features_array=devinfo.features)
if reply:
discharge, dischargeNext, status = reply
return STATUS.CONNECTED, {PROPS.BATTERY_LEVEL: discharge}
def default_process_event(devinfo, listener, data):
feature_index = ord(data[0:1])
feature = devinfo.features[feature_index]
if feature == _api.C.FEATURE.BATTERY:
if ord(data[1:2]) & 0xF0 == 0:
discharge = ord(data[2:3])
status = _api.C.BATTERY_STATUS[ord(data[3:4])]
return STATUS.CONNECTED, {BATTERY_LEVEL: discharge, BATTERY_STATUS: status}
# ?
elif feature == _api.C.FEATURE.REPROGRAMMABLE_KEYS:
if ord(data[1:2]) & 0xF0 == 0:
print 'reprogrammable key', repr(data)
# TODO
pass
# ?
elif feature == _api.C.FEATURE.WIRELESS:
if ord(data[1:2]) & 0xF0 == 0:
# TODO
pass
# ?
_REQUEST_STATUS_FUNCTIONS = _FDict()
_REQUEST_STATUS_FUNCTIONS[k750.NAME] = k750.request_status
def request_status(devinfo, listener): def request_status(devinfo, listener):
if devinfo.name in _REQUEST_STATUS_FUNCTIONS: return _REQUEST_STATUS_FUNCTIONS[devinfo.name](devinfo, listener) or default_request_status(devinfo, listener) or ping(devinfo, listener)
return _REQUEST_STATUS_FUNCTIONS[devinfo.name](devinfo, listener)
_PROCESS_EVENT_FUNCTIONS = { _PROCESS_EVENT_FUNCTIONS = _FDict()
k750.NAME : k750.process_event, _PROCESS_EVENT_FUNCTIONS[k750.NAME] = k750.process_event
}
def process_event(devinfo, listener, data): def process_event(devinfo, listener, data):
if devinfo.name in _PROCESS_EVENT_FUNCTIONS: return default_process_event(devinfo, listener, data) or _PROCESS_EVENT_FUNCTIONS[devinfo.name](devinfo, listener, data)
return _PROCESS_EVENT_FUNCTIONS[devinfo.name](devinfo, listener, data)

View File

@ -2,7 +2,7 @@
# #
# #
DEVICE_STATUS = type('DEVICE_STATUS', (), STATUS = type('STATUS', (),
dict( dict(
UNKNOWN=None, UNKNOWN=None,
UNAVAILABLE=-1, UNAVAILABLE=-1,
@ -10,12 +10,21 @@ DEVICE_STATUS = type('DEVICE_STATUS', (),
# ACTIVE=1, # ACTIVE=1,
)) ))
PROPS = type('PROPS', (),
dict(
TEXT='text',
BATTERY_LEVEL='battery-level',
BATTERY_STATUS='battery-status',
LIGHT_LUX='lux',
LIGHT_LEVEL='light-level',
))
from collections import defaultdict from collections import defaultdict
DEVICE_STATUS_NAME = defaultdict(lambda x: None) STATUS_NAME = defaultdict(lambda x: None)
DEVICE_STATUS_NAME[DEVICE_STATUS.UNAVAILABLE] = 'disconnected' STATUS_NAME[STATUS.UNAVAILABLE] = 'disconnected'
DEVICE_STATUS_NAME[DEVICE_STATUS.CONNECTED] = 'connected' STATUS_NAME[STATUS.CONNECTED] = 'connected'
# DEVICE_STATUS_NAME[DEVICE_STATUS.ACTIVE] = 'active' # STATUS_NAME[STATUS.ACTIVE] = 'active'
del defaultdict del defaultdict

View File

@ -3,10 +3,10 @@
# #
import logging import logging
import struct from struct import unpack as _unpack
from ..unifying_receiver import api as _api from ..unifying_receiver import api as _api
from .constants import * from . import constants as C
# #
# #
@ -14,10 +14,7 @@ from .constants import *
NAME = 'Wireless Solar Keyboard K750' NAME = 'Wireless Solar Keyboard K750'
_STATUS_NAMES = ('excellent', 'good', 'okay', 'poor', 'very low')
_CHARGE_LIMITS = (75, 40, 20, 10, -1) _CHARGE_LIMITS = (75, 40, 20, 10, -1)
_LIGHTING_LIMITS = (400, 200, 50, 20, -1)
# #
# #
@ -25,58 +22,55 @@ _LIGHTING_LIMITS = (400, 200, 50, 20, -1)
def _trigger_solar_charge_events(receiver, devinfo): def _trigger_solar_charge_events(receiver, devinfo):
return _api.request(receiver, devinfo.number, return _api.request(receiver, devinfo.number,
feature=_api.FEATURE.SOLAR_CHARGE, function=b'\x03', params=b'\x78\x01', feature=_api.C.FEATURE.SOLAR_CHARGE, function=b'\x03', params=b'\x78\x01',
features_array=devinfo.features) features_array=devinfo.features)
def _charge_status(data): def _charge_status(data):
charge, lux = struct.unpack('!BH', data[2:5]) charge, lux = _unpack('!BH', data[2:5])
d = {}
for i in range(0, len(_CHARGE_LIMITS)): for i in range(0, len(_CHARGE_LIMITS)):
if charge >= _CHARGE_LIMITS[i]: if charge >= _CHARGE_LIMITS[i]:
charge_index = i charge_index = i
break break
text = 'Charge %d%% (%s)' % (charge, _STATUS_NAMES[charge_index]) else:
charge_index = 0
d[C.PROPS.BATTERY_LEVEL] = charge
text = 'Battery %d%%' % charge
if lux > 0: if lux > 0:
for i in range(0, len(_CHARGE_LIMITS)): d[C.PROPS.LIGHT_LEVEL] = lux
if lux > _LIGHTING_LIMITS[i]: text = 'Light: %d lux' % lux + ', ' + text
lighting_index = i
break
text += ', Lighting %s (%d lux)' % (_STATUS_NAMES[lighting_index], lux)
return 0x10 << charge_index, text d[C.PROPS.TEXT] = text
return 0x10 << charge_index, d
def request_status(devinfo, listener): def request_status(devinfo, listener):
# Constantly requesting the solar charge status triggers a flood of events, reply = listener.request(_trigger_solar_charge_events, devinfo)
# which appear to drain the battery rather fast. if reply is None:
# Instead, ping the device for on/off status, and only ask for solar charge return C.STATUS.UNAVAILABLE
# status when the user presses the solar key on the keyboard.
#
# reply = listener.request(_trigger_solar_charge_events, devinfo)
# if reply is None:
# return DEVICE_STATUS.UNAVAILABLE
reply = listener.request(_api.ping, devinfo.number)
if not reply:
return DEVICE_STATUS.UNAVAILABLE
def process_event(devinfo, listener, data): def process_event(devinfo, listener, data):
if data[:2] == b'\x09\x00' and data[7:11] == b'GOOD':
# usually sent after the keyboard is turned on
return _charge_status(data)
if data[:2] == b'\x09\x10' and data[7:11] == b'GOOD':
# regular solar charge events
return _charge_status(data)
if data[:2] == b'\x09\x20' and data[7:11] == b'GOOD':
logging.debug("Solar key pressed")
if _trigger_solar_charge_events(listener.receiver, devinfo) is None:
return C.STATUS.UNAVAILABLE
return _charge_status(data)
if data[:2] == b'\x05\x00': if data[:2] == b'\x05\x00':
# wireless device status # wireless device status
if data[2:5] == b'\x01\x01\x01': if data[2:5] == b'\x01\x01\x01':
logging.debug("Keyboard just started") logging.debug("Keyboard just started")
return DEVICE_STATUS.CONNECTED return C.STATUS.CONNECTED
elif data[:2] == b'\x09\x00' and data[7:11] == b'GOOD':
# usually sent after the keyboard is turned on
return _charge_status(data)
elif data[:2] == b'\x09\x10' and data[7:11] == b'GOOD':
# regular solar charge events
return _charge_status(data)
elif data[:2] == b'\x09\x20' and data[7:11] == b'GOOD':
logging.debug("Solar key pressed")
if _trigger_solar_charge_events(listener.receiver, devinfo) is None:
return DEVICE_STATUS.UNAVAILABLE
return _charge_status(data)

View File

@ -3,14 +3,17 @@
# #
import logging import logging
import struct from struct import pack as _pack
from binascii import hexlify from struct import unpack as _unpack
from binascii import hexlify as _hexlify
from .common import * from .common import FirmwareInfo
from .constants import * from .common import AttachedDeviceInfo
from .exceptions import * from .common import ReprogrammableKeyInfo
from . import base from . import constants as C
from .unhandled import _publish as _unhandled_publish from . import exceptions as E
from . import unhandled as _unhandled
from . import base as _base
_LOG_LEVEL = 5 _LOG_LEVEL = 5
@ -25,10 +28,10 @@ def open():
:returns: An open file handle for the found receiver, or ``None``. :returns: An open file handle for the found receiver, or ``None``.
""" """
for rawdevice in base.list_receiver_devices(): for rawdevice in _base.list_receiver_devices():
_l.log(_LOG_LEVEL, "checking %s", rawdevice) _l.log(_LOG_LEVEL, "checking %s", rawdevice)
receiver = base.try_open(rawdevice.path) receiver = _base.try_open(rawdevice.path)
if receiver: if receiver:
return receiver return receiver
@ -36,7 +39,7 @@ def open():
"""Closes a HID device handle.""" """Closes a HID device handle."""
close = base.close close = _base.close
def request(handle, device, feature, function=b'\x00', params=b'', features_array=None): def request(handle, device, feature, function=b'\x00', params=b'', features_array=None):
@ -58,7 +61,7 @@ def request(handle, device, feature, function=b'\x00', params=b'', features_arra
""" """
feature_index = None feature_index = None
if feature == FEATURE.ROOT: if feature == C.FEATURE.ROOT:
feature_index = b'\x00' feature_index = b'\x00'
else: else:
if features_array is None: if features_array is None:
@ -67,13 +70,13 @@ def request(handle, device, feature, function=b'\x00', params=b'', features_arra
_l.log(_LOG_LEVEL, "(%d,%d) no features array available", handle, device) _l.log(_LOG_LEVEL, "(%d,%d) no features array available", handle, device)
return None return None
if feature in features_array: if feature in features_array:
feature_index = struct.pack('!B', features_array.index(feature)) feature_index = _pack('!B', features_array.index(feature))
if feature_index is None: if feature_index is None:
_l.warn("(%d,%d) feature <%s:%s> not supported", handle, device, hexlify(feature), FEATURE_NAME[feature]) _l.warn("(%d,%d) feature <%s:%s> not supported", handle, device, _hexlify(feature), C.FEATURE_NAME[feature])
raise FeatureNotSupported(device, feature) raise E.FeatureNotSupported(device, feature)
return base.request(handle, device, feature_index + function, params) return _base.request(handle, device, feature_index + function, params)
def ping(handle, device): def ping(handle, device):
@ -93,13 +96,13 @@ def ping(handle, device):
if reply_device != device: if reply_device != device:
# oops # oops
_l.log(_LOG_LEVEL, "(%d,%d) ping: reply for another device %d: %s", handle, device, reply_device, hexlify(reply_data)) _l.log(_LOG_LEVEL, "(%d,%d) ping: reply for another device %d: %s", handle, device, reply_device, _hexlify(reply_data))
_unhandled_publish(reply_code, reply_device, reply_data) _unhandled._publish(reply_code, reply_device, reply_data)
return _status(base.read(handle)) return _status(_base.read(handle))
if (reply_code == 0x11 and reply_data[:2] == b'\x00\x10' and reply_data[4:5] == ping_marker): if (reply_code == 0x11 and reply_data[:2] == b'\x00\x10' and reply_data[4:5] == ping_marker):
# ping ok # ping ok
_l.log(_LOG_LEVEL, "(%d,%d) ping: ok [%s]", handle, device, hexlify(reply_data)) _l.log(_LOG_LEVEL, "(%d,%d) ping: ok [%s]", handle, device, _hexlify(reply_data))
return True return True
if (reply_code == 0x10 and reply_data[:2] == b'\x8F\x00'): if (reply_code == 0x10 and reply_data[:2] == b'\x8F\x00'):
@ -111,19 +114,19 @@ def ping(handle, device):
# some devices may reply with a SOLAR_CHARGE event before the # some devices may reply with a SOLAR_CHARGE event before the
# ping_ok reply, especially right after the device connected to the # ping_ok reply, especially right after the device connected to the
# receiver # receiver
_l.log(_LOG_LEVEL, "(%d,%d) ping: solar status %s", handle, device, hexlify(reply_data)) _l.log(_LOG_LEVEL, "(%d,%d) ping: solar status [%s]", handle, device, _hexlify(reply_data))
_unhandled_publish(reply_code, reply_device, reply_data) _unhandled._publish(reply_code, reply_device, reply_data)
return _status(base.read(handle)) return _status(_base.read(handle))
# ugh # ugh
_l.log(_LOG_LEVEL, "(%d,%d) ping: unknown reply for this device: %d=[%s]", handle, device, reply[0], hexlify(reply[2])) _l.log(_LOG_LEVEL, "(%d,%d) ping: unknown reply for this device: %d=[%s]", handle, device, reply_code, _hexlify(reply_data))
_unhandled_publish(reply_code, reply_device, reply_data) _unhandled._publish(reply_code, reply_device, reply_data)
return None return None
_l.log(_LOG_LEVEL, "(%d,%d) pinging", handle, device) _l.log(_LOG_LEVEL, "(%d,%d) pinging", handle, device)
base.write(handle, device, b'\x00\x10\x00\x00' + ping_marker) _base.write(handle, device, b'\x00\x10\x00\x00' + ping_marker)
# pings may take a while to reply success # pings may take a while to reply success
return _status(base.read(handle, base.DEFAULT_TIMEOUT * 3)) return _status(_base.read(handle, _base.DEFAULT_TIMEOUT * 3))
def find_device_by_name(handle, device_name): def find_device_by_name(handle, device_name):
@ -133,7 +136,7 @@ def find_device_by_name(handle, device_name):
""" """
_l.log(_LOG_LEVEL, "(%d,) searching for device '%s'", handle, device_name) _l.log(_LOG_LEVEL, "(%d,) searching for device '%s'", handle, device_name)
for device in range(1, 1 + base.MAX_ATTACHED_DEVICES): for device in range(1, 1 + _base.MAX_ATTACHED_DEVICES):
features_array = get_device_features(handle, device) features_array = get_device_features(handle, device)
if features_array: if features_array:
d_name = get_device_name(handle, device, features_array) d_name = get_device_name(handle, device, features_array)
@ -150,7 +153,7 @@ def list_devices(handle):
devices = [] devices = []
for device in range(1, 1 + base.MAX_ATTACHED_DEVICES): for device in range(1, 1 + _base.MAX_ATTACHED_DEVICES):
features_array = get_device_features(handle, device) features_array = get_device_features(handle, device)
if features_array: if features_array:
devices.append(get_device_info(handle, device, features_array=features_array)) devices.append(get_device_info(handle, device, features_array=features_array))
@ -181,31 +184,31 @@ def get_feature_index(handle, device, feature):
:returns: An int, or ``None`` if the feature is not available. :returns: An int, or ``None`` if the feature is not available.
""" """
_l.log(_LOG_LEVEL, "(%d,%d) get feature index <%s:%s>", handle, device, hexlify(feature), FEATURE_NAME[feature]) _l.log(_LOG_LEVEL, "(%d,%d) get feature index <%s:%s>", handle, device, _hexlify(feature), C.FEATURE_NAME[feature])
if len(feature) != 2: if len(feature) != 2:
raise ValueError("invalid feature <%s>: it must be a two-byte string" % feature) raise ValueError("invalid feature <%s>: it must be a two-byte string" % feature)
# FEATURE.ROOT should always be available for any attached devices # FEATURE.ROOT should always be available for any attached devices
reply = base.request(handle, device, FEATURE.ROOT, feature) reply = _base.request(handle, device, C.FEATURE.ROOT, feature)
if reply: if reply:
# only consider active and supported features # only consider active and supported features
feature_index = ord(reply[0:1]) feature_index = ord(reply[0:1])
if feature_index: if feature_index:
feature_flags = ord(reply[1:2]) & 0xE0 feature_flags = ord(reply[1:2]) & 0xE0
_l.log(_LOG_LEVEL, "(%d,%d) feature <%s:%s> has index %d flags %02x", handle, device, hexlify(feature), FEATURE_NAME[feature], feature_index, feature_flags) _l.log(_LOG_LEVEL, "(%d,%d) feature <%s:%s> has index %d flags %02x", handle, device, _hexlify(feature), C.FEATURE_NAME[feature], feature_index, feature_flags)
if feature_flags == 0: if feature_flags == 0:
return feature_index return feature_index
if feature_flags & 0x80: if feature_flags & 0x80:
_l.warn("(%d,%d) feature <%s:%s> not supported: obsolete", handle, device, hexlify(feature), FEATURE_NAME[feature]) _l.warn("(%d,%d) feature <%s:%s> not supported: obsolete", handle, device, _hexlify(feature), C.FEATURE_NAME[feature])
if feature_flags & 0x40: if feature_flags & 0x40:
_l.warn("(%d,%d) feature <%s:%s> not supported: hidden", handle, device, hexlify(feature), FEATURE_NAME[feature]) _l.warn("(%d,%d) feature <%s:%s> not supported: hidden", handle, device, _hexlify(feature), C.FEATURE_NAME[feature])
if feature_flags & 0x20: if feature_flags & 0x20:
_l.warn("(%d,%d) feature <%s:%s> not supported: Logitech internal", handle, device, hexlify(feature), FEATURE_NAME[feature]) _l.warn("(%d,%d) feature <%s:%s> not supported: Logitech internal", handle, device, _hexlify(feature), C.FEATURE_NAME[feature])
raise FeatureNotSupported(device, feature) raise E.FeatureNotSupported(device, feature)
else: else:
_l.warn("(%d,%d) feature <%s:%s> not supported by the device", handle, device, hexlify(feature), FEATURE_NAME[feature]) _l.warn("(%d,%d) feature <%s:%s> not supported by the device", handle, device, _hexlify(feature), C.FEATURE_NAME[feature])
raise FeatureNotSupported(device, feature) raise E.FeatureNotSupported(device, feature)
def get_device_features(handle, device): def get_device_features(handle, device):
@ -218,7 +221,7 @@ def get_device_features(handle, device):
# get the index of the FEATURE_SET # get the index of the FEATURE_SET
# FEATURE.ROOT should always be available for all devices # FEATURE.ROOT should always be available for all devices
fs_index = base.request(handle, device, FEATURE.ROOT, FEATURE.FEATURE_SET) fs_index = _base.request(handle, device, C.FEATURE.ROOT, C.FEATURE.FEATURE_SET)
if fs_index is None: if fs_index is None:
# _l.warn("(%d,%d) FEATURE_SET not available", handle, device) # _l.warn("(%d,%d) FEATURE_SET not available", handle, device)
return None return None
@ -228,7 +231,7 @@ def get_device_features(handle, device):
# even if unknown. # even if unknown.
# get the number of active features the device has # get the number of active features the device has
features_count = base.request(handle, device, fs_index + b'\x00') features_count = _base.request(handle, device, fs_index + b'\x00')
if not features_count: if not features_count:
# this can happen if the device disappeard since the fs_index request # this can happen if the device disappeard since the fs_index request
# otherwise we should get at least a count of 1 (the FEATURE_SET we've just used above) # otherwise we should get at least a count of 1 (the FEATURE_SET we've just used above)
@ -238,17 +241,19 @@ def get_device_features(handle, device):
features_count = ord(features_count[:1]) features_count = ord(features_count[:1])
_l.log(_LOG_LEVEL, "(%d,%d) found %d features", handle, device, features_count) _l.log(_LOG_LEVEL, "(%d,%d) found %d features", handle, device, features_count)
# a device may have a maximum of 15 features, other than FEATURE.ROOT features = [None] * 0x20
features = [None] * 0x10
for index in range(1, 1 + features_count): for index in range(1, 1 + features_count):
# for each index, get the feature residing at that index # for each index, get the feature residing at that index
feature = base.request(handle, device, fs_index + b'\x10', struct.pack('!B', index)) feature = _base.request(handle, device, fs_index + b'\x10', _pack('!B', index))
if feature: if feature:
feature = feature[0:2].upper() feature = feature[0:2].upper()
features[index] = feature features[index] = feature
_l.log(_LOG_LEVEL, "(%d,%d) feature <%s:%s> at index %d", handle, device, hexlify(feature), FEATURE_NAME[feature], index) _l.log(_LOG_LEVEL, "(%d,%d) feature <%s:%s> at index %d", handle, device, _hexlify(feature), C.FEATURE_NAME[feature], index)
return None if all(c == None for c in features) else features features[0] = C.FEATURE.ROOT
while features[-1] is None:
del features[-1]
return features
def get_device_firmware(handle, device, features_array=None): def get_device_firmware(handle, device, features_array=None):
@ -259,35 +264,35 @@ def get_device_firmware(handle, device, features_array=None):
def _makeFirmwareInfo(level, type, name=None, version=None, build=None, extras=None): def _makeFirmwareInfo(level, type, name=None, version=None, build=None, extras=None):
return FirmwareInfo(level, type, name, version, build, extras) return FirmwareInfo(level, type, name, version, build, extras)
fw_count = request(handle, device, FEATURE.FIRMWARE, features_array=features_array) fw_count = request(handle, device, C.FEATURE.FIRMWARE, features_array=features_array)
if fw_count: if fw_count:
fw_count = ord(fw_count[:1]) fw_count = ord(fw_count[:1])
fw = [] fw = []
for index in range(0, fw_count): for index in range(0, fw_count):
index = struct.pack('!B', index) index = _pack('!B', index)
fw_info = request(handle, device, FEATURE.FIRMWARE, function=b'\x10', params=index, features_array=features_array) fw_info = request(handle, device, C.FEATURE.FIRMWARE, function=b'\x10', params=index, features_array=features_array)
if fw_info: if fw_info:
fw_level = ord(fw_info[:1]) & 0x0F fw_level = ord(fw_info[:1]) & 0x0F
if fw_level == 0 or fw_level == 1: if fw_level == 0 or fw_level == 1:
fw_type = FIRMWARE_TYPE[fw_level] fw_type = C.FIRMWARE_TYPE[fw_level]
name, = struct.unpack('!3s', fw_info[1:4]) name, = _unpack('!3s', fw_info[1:4])
name = name.decode('ascii') name = name.decode('ascii')
version = ( chr(0x30 + (ord(fw_info[4:5]) >> 4)) + version = ( chr(0x30 + (ord(fw_info[4:5]) >> 4)) +
chr(0x30 + (ord(fw_info[4:5]) & 0x0F)) + chr(0x30 + (ord(fw_info[4:5]) & 0x0F)) +
'.' + '.' +
chr(0x30 + (ord(fw_info[5:6]) >> 4)) + chr(0x30 + (ord(fw_info[5:6]) >> 4)) +
chr(0x30 + (ord(fw_info[5:6]) & 0x0F))) chr(0x30 + (ord(fw_info[5:6]) & 0x0F)))
build, = struct.unpack('!H', fw_info[6:8]) build, = _unpack('!H', fw_info[6:8])
extras = fw_info[9:].rstrip(b'\x00') extras = fw_info[9:].rstrip(b'\x00')
if extras: if extras:
fw_info = _makeFirmwareInfo(level=fw_level, type=fw_type, name=name, version=version, build=build, extras=extras) fw_info = _makeFirmwareInfo(level=fw_level, type=fw_type, name=name, version=version, build=build, extras=extras)
else: else:
fw_info = _makeFirmwareInfo(level=fw_level, type=fw_type, name=name, version=version, build=build) fw_info = _makeFirmwareInfo(level=fw_level, type=fw_type, name=name, version=version, build=build)
elif fw_level == 2: elif fw_level == 2:
fw_info = _makeFirmwareInfo(level=2, type=FIRMWARE_TYPE[2], version=ord(fw_info[1:2])) fw_info = _makeFirmwareInfo(level=2, type=C.FIRMWARE_TYPE[2], version=ord(fw_info[1:2]))
else: else:
fw_info = _makeFirmwareInfo(level=fw_level, type=FIRMWARE_TYPE[-1]) fw_info = _makeFirmwareInfo(level=fw_level, type=C.FIRMWARE_TYPE[-1])
fw.append(fw_info) fw.append(fw_info)
_l.log(_LOG_LEVEL, "(%d:%d) firmware %s", handle, device, fw_info) _l.log(_LOG_LEVEL, "(%d:%d) firmware %s", handle, device, fw_info)
@ -301,11 +306,11 @@ def get_device_type(handle, device, features_array=None):
:returns: a string describing the device type, or ``None`` if the device is :returns: a string describing the device type, or ``None`` if the device is
not available or does not support the ``NAME`` feature. not available or does not support the ``NAME`` feature.
""" """
d_type = request(handle, device, FEATURE.NAME, function=b'\x20', features_array=features_array) d_type = request(handle, device, C.FEATURE.NAME, function=b'\x20', features_array=features_array)
if d_type: if d_type:
d_type = ord(d_type[:1]) d_type = ord(d_type[:1])
_l.log(_LOG_LEVEL, "(%d,%d) device type %d = %s", handle, device, d_type, DEVICE_TYPE[d_type]) _l.log(_LOG_LEVEL, "(%d,%d) device type %d = %s", handle, device, d_type, C.DEVICE_TYPE[d_type])
return DEVICE_TYPE[d_type] return C.DEVICE_TYPE[d_type]
def get_device_name(handle, device, features_array=None): def get_device_name(handle, device, features_array=None):
@ -314,14 +319,14 @@ def get_device_name(handle, device, features_array=None):
:returns: a string with the device name, or ``None`` if the device is not :returns: a string with the device name, or ``None`` if the device is not
available or does not support the ``NAME`` feature. available or does not support the ``NAME`` feature.
""" """
name_length = request(handle, device, FEATURE.NAME, features_array=features_array) name_length = request(handle, device, C.FEATURE.NAME, features_array=features_array)
if name_length: if name_length:
name_length = ord(name_length[:1]) name_length = ord(name_length[:1])
d_name = b'' d_name = b''
while len(d_name) < name_length: while len(d_name) < name_length:
name_index = struct.pack('!B', len(d_name)) name_index = _pack('!B', len(d_name))
name_fragment = request(handle, device, FEATURE.NAME, function=b'\x10', params=name_index, features_array=features_array) name_fragment = request(handle, device, C.FEATURE.NAME, function=b'\x10', params=name_index, features_array=features_array)
name_fragment = name_fragment[:name_length - len(d_name)] name_fragment = name_fragment[:name_length - len(d_name)]
d_name += name_fragment d_name += name_fragment
@ -335,24 +340,24 @@ def get_device_battery_level(handle, device, features_array=None):
:raises FeatureNotSupported: if the device does not support this feature. :raises FeatureNotSupported: if the device does not support this feature.
""" """
battery = request(handle, device, FEATURE.BATTERY, features_array=features_array) battery = request(handle, device, C.FEATURE.BATTERY, features_array=features_array)
if battery: if battery:
discharge, dischargeNext, status = struct.unpack('!BBB', battery[:3]) discharge, dischargeNext, status = _unpack('!BBB', battery[:3])
_l.log(_LOG_LEVEL, "(%d:%d) battery %d%% charged, next level %d%% charge, status %d = %s", discharge, dischargeNext, status, BATTERY_STATUSE[status]) _l.log(_LOG_LEVEL, "(%d:%d) battery %d%% charged, next level %d%% charge, status %d = %s", discharge, dischargeNext, status, C.BATTERY_STATUSE[status])
return (discharge, dischargeNext, BATTERY_STATUS[status]) return (discharge, dischargeNext, C.BATTERY_STATUS[status])
def get_device_keys(handle, device, features_array=None): def get_device_keys(handle, device, features_array=None):
count = request(handle, device, FEATURE.REPROGRAMMABLE_KEYS, features_array=features_array) count = request(handle, device, C.FEATURE.REPROGRAMMABLE_KEYS, features_array=features_array)
if count: if count:
keys = [] keys = []
count = ord(count[:1]) count = ord(count[:1])
for index in range(0, count): for index in range(0, count):
keyindex = struct.pack('!B', index) keyindex = _pack('!B', index)
keydata = request(handle, device, FEATURE.REPROGRAMMABLE_KEYS, function=b'\x10', params=keyindex, features_array=features_array) keydata = request(handle, device, C.FEATURE.REPROGRAMMABLE_KEYS, function=b'\x10', params=keyindex, features_array=features_array)
if keydata: if keydata:
key, key_task, flags = struct.unpack('!HHB', keydata[:5]) key, key_task, flags = _unpack('!HHB', keydata[:5])
keys.append(ReprogrammableKeyInfo(index, key, KEY_NAME[key], key_task, KEY_NAME[key_task], flags)) keys.append(ReprogrammableKeyInfo(index, key, C.KEY_NAME[key], key_task, C.KEY_NAME[key_task], flags))
return keys return keys

View File

@ -4,11 +4,11 @@
# #
import logging import logging
import struct from struct import pack as _pack
from binascii import hexlify from binascii import hexlify as _hexlify
from .constants import * import constants as C
from .exceptions import * import exceptions as E
from . import unhandled as _unhandled from . import unhandled as _unhandled
import hidapi as _hid import hidapi as _hid
@ -92,9 +92,9 @@ def try_open(path):
# any other replies are ignored, and will assume this is the wrong receiver device # any other replies are ignored, and will assume this is the wrong receiver device
if reply == b'\x01\x00\x00\x00\x00\x00\x00\x00': if reply == b'\x01\x00\x00\x00\x00\x00\x00\x00':
# no idea what this is, but it comes up occasionally # no idea what this is, but it comes up occasionally
_l.log(_LOG_LEVEL, "[%s] (%d,) mistery reply [%s]", path, receiver_handle, hexlify(reply)) _l.log(_LOG_LEVEL, "[%s] (%d,) mistery reply [%s]", path, receiver_handle, _hexlify(reply))
else: else:
_l.log(_LOG_LEVEL, "[%s] (%d,) unknown reply [%s]", path, receiver_handle, hexlify(reply)) _l.log(_LOG_LEVEL, "[%s] (%d,) unknown reply [%s]", path, receiver_handle, _hexlify(reply))
else: else:
_l.log(_LOG_LEVEL, "[%s] (%d,) no reply", path, receiver_handle) _l.log(_LOG_LEVEL, "[%s] (%d,) no reply", path, receiver_handle)
@ -146,16 +146,18 @@ def write(handle, device, data):
data += b'\x00' * (_MIN_CALL_SIZE - 2 - len(data)) data += b'\x00' * (_MIN_CALL_SIZE - 2 - len(data))
elif len(data) > _MIN_CALL_SIZE - 2: elif len(data) > _MIN_CALL_SIZE - 2:
data += b'\x00' * (_MAX_CALL_SIZE - 2 - len(data)) data += b'\x00' * (_MAX_CALL_SIZE - 2 - len(data))
wdata = struct.pack('!BB', 0x10, device) + data wdata = _pack('!BB', 0x10, device) + data
_l.log(_LOG_LEVEL, "(%d,%d) <= w[%s]", handle, device, hexlify(wdata))
_l.log(_LOG_LEVEL, "(%d,%d) <= w[%s]", handle, device, _hexlify(wdata))
if len(wdata) < _MIN_CALL_SIZE: if len(wdata) < _MIN_CALL_SIZE:
_l.warn("(%d:%d) <= w[%s] call packet too short: %d bytes", handle, device, hexlify(wdata), len(wdata)) _l.warn("(%d:%d) <= w[%s] call packet too short: %d bytes", handle, device, _hexlify(wdata), len(wdata))
if len(wdata) > _MAX_CALL_SIZE: if len(wdata) > _MAX_CALL_SIZE:
_l.warn("(%d:%d) <= w[%s] call packet too long: %d bytes", handle, device, hexlify(wdata), len(wdata)) _l.warn("(%d:%d) <= w[%s] call packet too long: %d bytes", handle, device, _hexlify(wdata), len(wdata))
if not _hid.write(handle, wdata): if not _hid.write(handle, wdata):
_l.warn("(%d,%d) write failed, assuming receiver no longer available", handle, device) _l.warn("(%d,%d) write failed, assuming receiver no longer available", handle, device)
close(handle) close(handle)
raise NoReceiver() raise E.NoReceiver
def read(handle, timeout=DEFAULT_TIMEOUT): def read(handle, timeout=DEFAULT_TIMEOUT):
@ -178,17 +180,18 @@ def read(handle, timeout=DEFAULT_TIMEOUT):
if data is None: if data is None:
_l.warn("(%d,*) read failed, assuming receiver no longer available", handle) _l.warn("(%d,*) read failed, assuming receiver no longer available", handle)
close(handle) close(handle)
raise NoReceiver raise E.NoReceiver
if data: if data:
_l.log(_LOG_LEVEL, "(%d,*) => r[%s]", handle, hexlify(data)) _l.log(_LOG_LEVEL, "(%d,*) => r[%s]", handle, _hexlify(data))
if len(data) < _MIN_REPLY_SIZE: if len(data) < _MIN_REPLY_SIZE:
_l.warn("(%d,*) => r[%s] read packet too short: %d bytes", handle, hexlify(data), len(data)) _l.warn("(%d,*) => r[%s] read packet too short: %d bytes", handle, _hexlify(data), len(data))
if len(data) > _MAX_REPLY_SIZE: if len(data) > _MAX_REPLY_SIZE:
_l.warn("(%d,*) => r[%s] read packet too long: %d bytes", handle, hexlify(data), len(data)) _l.warn("(%d,*) => r[%s] read packet too long: %d bytes", handle, _hexlify(data), len(data))
code = ord(data[:1]) code = ord(data[:1])
device = ord(data[1:2]) device = ord(data[1:2])
return code, device, data[2:] return code, device, data[2:]
_l.log(_LOG_LEVEL, "(%d,*) => r[]", handle) _l.log(_LOG_LEVEL, "(%d,*) => r[]", handle)
@ -209,13 +212,16 @@ def request(handle, device, feature_index_function, params=b'', features_array=N
available. available.
:raisees FeatureCallError: if the feature call replied with an error. :raisees FeatureCallError: if the feature call replied with an error.
""" """
_l.log(_LOG_LEVEL, "(%d,%d) request {%s} params [%s]", handle, device, hexlify(feature_index_function), hexlify(params)) _l.log(_LOG_LEVEL, "(%d,%d) request {%s} params [%s]", handle, device, _hexlify(feature_index_function), _hexlify(params))
if len(feature_index_function) != 2: if len(feature_index_function) != 2:
raise ValueError('invalid feature_index_function {%s}: it must be a two-byte string' % hexlify(feature_index_function)) raise ValueError('invalid feature_index_function {%s}: it must be a two-byte string' % _hexlify(feature_index_function))
retries = 5
write(handle, device, feature_index_function + params) write(handle, device, feature_index_function + params)
while True: while retries > 0:
reply = read(handle) reply = read(handle)
retries -= 1
if not reply: if not reply:
# keep waiting... # keep waiting...
@ -225,7 +231,7 @@ def request(handle, device, feature_index_function, params=b'', features_array=N
if reply_device != device: if reply_device != device:
# this message not for the device we're interested in # this message not for the device we're interested in
_l.log(_LOG_LEVEL, "(%d,%d) request got reply for unexpected device %d: [%s]", handle, device, reply_device, hexlify(reply_data)) _l.log(_LOG_LEVEL, "(%d,%d) request got reply for unexpected device %d: [%s]", handle, device, reply_device, _hexlify(reply_data))
# worst case scenario, this is a reply for a concurrent request # worst case scenario, this is a reply for a concurrent request
# on this receiver # on this receiver
_unhandled._publish(reply_code, reply_device, reply_data) _unhandled._publish(reply_code, reply_device, reply_data)
@ -233,27 +239,27 @@ def request(handle, device, feature_index_function, params=b'', features_array=N
if reply_code == 0x10 and reply_data[:1] == b'\x8F' and reply_data[1:2] == feature_index_function: if reply_code == 0x10 and reply_data[:1] == b'\x8F' and reply_data[1:2] == feature_index_function:
# device not present # device not present
_l.log(_LOG_LEVEL, "(%d,%d) request ping failed on {%s} call: [%s]", handle, device, hexlify(feature_index_function), hexlify(reply_data)) _l.log(_LOG_LEVEL, "(%d,%d) request ping failed on {%s} call: [%s]", handle, device, _hexlify(feature_index_function), _hexlify(reply_data))
return None return None
if reply_code == 0x10 and reply_data[:1] == b'\x8F': if reply_code == 0x10 and reply_data[:1] == b'\x8F':
# device not present # device not present
_l.log(_LOG_LEVEL, "(%d,%d) request ping failed: [%s]", handle, device, hexlify(reply_data)) _l.log(_LOG_LEVEL, "(%d,%d) request ping failed: [%s]", handle, device, _hexlify(reply_data))
return None return None
if reply_code == 0x11 and reply_data[0] == b'\xFF' and reply_data[1:3] == feature_index_function: if reply_code == 0x11 and reply_data[0] == b'\xFF' and reply_data[1:3] == feature_index_function:
# an error returned from the device # an error returned from the device
error_code = ord(reply_data[3]) error_code = ord(reply_data[3])
_l.warn("(%d,%d) request feature call error %d = %s: %s", handle, device, error_code, ERROR_NAME[error_code], hexlify(reply_data)) _l.warn("(%d,%d) request feature call error %d = %s: %s", handle, device, error_code, C.ERROR_NAME[error_code], _hexlify(reply_data))
feature_index = ord(feature_index_function[:1]) feature_index = ord(feature_index_function[:1])
feature_function = feature_index_function[1:2] feature_function = feature_index_function[1:2]
feature = None if features_array is None else features_array[feature_index] feature = None if features_array is None else features_array[feature_index]
raise FeatureCallError(device, feature, feature_index, feature_function, error_code, reply_data) raise E.FeatureCallError(device, feature, feature_index, feature_function, error_code, reply_data)
if reply_code == 0x11 and reply_data[:2] == feature_index_function: if reply_code == 0x11 and reply_data[:2] == feature_index_function:
# a matching reply # a matching reply
_l.log(_LOG_LEVEL, "(%d,%d) matched reply with data [%s]", handle, device, hexlify(reply_data[2:])) _l.log(_LOG_LEVEL, "(%d,%d) matched reply with feature-index-function [%s]", handle, device, _hexlify(reply_data[2:]))
return reply_data[2:] return reply_data[2:]
_l.log(_LOG_LEVEL, "(%d,%d) unmatched reply {%s} (expected {%s})", handle, device, hexlify(reply_data[:2]), hexlify(feature_index_function)) _l.log(_LOG_LEVEL, "(%d,%d) unmatched reply {%s} (expected {%s})", handle, device, _hexlify(reply_data[:2]), _hexlify(feature_index_function))
_unhandled._publish(reply_code, reply_device, reply_data) _unhandled._publish(reply_code, reply_device, reply_data)

View File

@ -3,7 +3,7 @@
# #
class FallbackDict(dict): class FallbackDict(dict):
def __init__(self, fallback_function, *args, **kwargs): def __init__(self, fallback_function=lambda x: None, *args, **kwargs):
super(FallbackDict, self).__init__(*args, **kwargs) super(FallbackDict, self).__init__(*args, **kwargs)
self.fallback = fallback_function self.fallback = fallback_function
@ -18,7 +18,6 @@ def list2dict(values_list):
return dict(zip(range(0, len(values_list)), values_list)) return dict(zip(range(0, len(values_list)), values_list))
from collections import namedtuple from collections import namedtuple
"""Tuple returned by list_devices and find_device_by_name.""" """Tuple returned by list_devices and find_device_by_name."""

View File

@ -5,7 +5,7 @@
from binascii import hexlify as _hexlify from binascii import hexlify as _hexlify
from struct import pack as _pack from struct import pack as _pack
from .common import * from .common import (FallbackDict, list2dict)
"""Possible features available on a Logitech device. """Possible features available on a Logitech device.
@ -21,7 +21,7 @@ FEATURE = type('FEATURE', (),
NAME=b'\x00\x05', NAME=b'\x00\x05',
BATTERY=b'\x10\x00', BATTERY=b'\x10\x00',
REPROGRAMMABLE_KEYS=b'\x1B\x00', REPROGRAMMABLE_KEYS=b'\x1B\x00',
WIRELESS_STATUS=b'\x1D\x4B', WIRELESS=b'\x1D\x4B',
SOLAR_CHARGE=b'\x43\x01', SOLAR_CHARGE=b'\x43\x01',
)) ))
@ -41,7 +41,7 @@ FEATURE_NAME[FEATURE.FIRMWARE] = 'FIRMWARE'
FEATURE_NAME[FEATURE.NAME] = 'NAME' FEATURE_NAME[FEATURE.NAME] = 'NAME'
FEATURE_NAME[FEATURE.BATTERY] = 'BATTERY' FEATURE_NAME[FEATURE.BATTERY] = 'BATTERY'
FEATURE_NAME[FEATURE.REPROGRAMMABLE_KEYS] = 'REPROGRAMMABLE_KEYS' FEATURE_NAME[FEATURE.REPROGRAMMABLE_KEYS] = 'REPROGRAMMABLE_KEYS'
FEATURE_NAME[FEATURE.WIRELESS_STATUS] = 'WIRELESS_STATUS' FEATURE_NAME[FEATURE.WIRELESS] = 'WIRELESS'
FEATURE_NAME[FEATURE.SOLAR_CHARGE] = 'SOLAR_CHARGE' FEATURE_NAME[FEATURE.SOLAR_CHARGE] = 'SOLAR_CHARGE'
@ -54,7 +54,7 @@ DEVICE_TYPE = FallbackDict(lambda x: 'unknown', list2dict(_DEVICE_TYPES))
_FIRMWARE_TYPES = ('Main (HID)', 'Bootloader', 'Hardware', 'Other') _FIRMWARE_TYPES = ('Main (HID)', 'Bootloader', 'Hardware', 'Other')
"""Names of different firmware levels possible, ordered from top to bottom.""" """Names of different firmware levels possible, indexed by level."""
FIRMWARE_TYPE = FallbackDict(lambda x: 'Unknown', list2dict(_FIRMWARE_TYPES)) FIRMWARE_TYPE = FallbackDict(lambda x: 'Unknown', list2dict(_FIRMWARE_TYPES))
@ -74,7 +74,7 @@ _KEY_NAMES = ( 'unknown_0000', 'Volume up', 'Volume down', 'Mute', 'Play/Pause',
KEY_NAME = FallbackDict(lambda x: 'unknown_%04x' % x, list2dict(_KEY_NAMES)) KEY_NAME = FallbackDict(lambda x: 'unknown_%04x' % x, list2dict(_KEY_NAMES))
"""Possible flags on a reprogrammable key.""" """Possible flags on a reprogrammable key."""
KEY_FLAG = type('REPROGRAMMABLE_KEY_FLAGS', (), dict( KEY_FLAG = type('KEY_FLAG', (), dict(
REPROGRAMMABLE=0x10, REPROGRAMMABLE=0x10,
FN_SENSITIVE=0x08, FN_SENSITIVE=0x08,
NONSTANDARD=0x04, NONSTANDARD=0x04,
@ -82,9 +82,20 @@ KEY_FLAG = type('REPROGRAMMABLE_KEY_FLAGS', (), dict(
MSE=0x01, MSE=0x01,
)) ))
KEY_FLAG_NAME = FallbackDict(lambda x: 'unknown')
KEY_FLAG_NAME[KEY_FLAG.REPROGRAMMABLE] = 'reprogrammable'
KEY_FLAG_NAME[KEY_FLAG.FN_SENSITIVE] = 'fn-sensitive'
KEY_FLAG_NAME[KEY_FLAG.NONSTANDARD] = 'nonstandard'
KEY_FLAG_NAME[KEY_FLAG.IS_FN] = 'is-fn'
KEY_FLAG_NAME[KEY_FLAG.MSE] = 'mse'
_ERROR_NAMES = ('Ok', 'Unknown', 'Invalid argument', 'Out of range', _ERROR_NAMES = ('Ok', 'Unknown', 'Invalid argument', 'Out of range',
'Hardware error', 'Logitech internal', 'Invalid feature index', 'Hardware error', 'Logitech internal', 'Invalid feature index',
'Invalid function', 'Busy', 'Unsupported') 'Invalid function', 'Busy', 'Unsupported')
"""Names for error codes.""" """Names for error codes."""
ERROR_NAME = FallbackDict(lambda x: 'Unknown error', list2dict(_ERROR_NAMES)) ERROR_NAME = FallbackDict(lambda x: 'Unknown error', list2dict(_ERROR_NAMES))
del FallbackDict
del list2dict

View File

@ -2,8 +2,7 @@
# Exceptions that may be raised by this API. # Exceptions that may be raised by this API.
# #
from .constants import FEATURE_NAME as _FEATURE_NAME from . import constants as C
from .constants import ERROR_NAME as _ERROR_NAME
class NoReceiver(Exception): class NoReceiver(Exception):
@ -17,21 +16,21 @@ class NoReceiver(Exception):
class FeatureNotSupported(Exception): class FeatureNotSupported(Exception):
"""Raised when trying to request a feature not supported by the device.""" """Raised when trying to request a feature not supported by the device."""
def __init__(self, device, feature): def __init__(self, device, feature):
super(FeatureNotSupported, self).__init__(device, feature, _FEATURE_NAME[feature]) super(FeatureNotSupported, self).__init__(device, feature, C.FEATURE_NAME[feature])
self.device = device self.device = device
self.feature = feature self.feature = feature
self.feature_name = _FEATURE_NAME[feature] self.feature_name = C.FEATURE_NAME[feature]
class FeatureCallError(Exception): class FeatureCallError(Exception):
"""Raised if the device replied to a feature call with an error.""" """Raised if the device replied to a feature call with an error."""
def __init__(self, device, feature, feature_index, feature_function, error_code, data=None): def __init__(self, device, feature, feature_index, feature_function, error_code, data=None):
super(FeatureCallError, self).__init__(device, feature, feature_index, feature_function, error_code, _ERROR_NAME[error_code]) super(FeatureCallError, self).__init__(device, feature, feature_index, feature_function, error_code, C.ERROR_NAME[error_code])
self.device = device self.device = device
self.feature = feature self.feature = feature
self.feature_name = None if feature is None else _FEATURE_NAME[feature] self.feature_name = None if feature is None else C.FEATURE_NAME[feature]
self.feature_index = feature_index self.feature_index = feature_index
self.feature_function = feature_function self.feature_function = feature_function
self.error_code = error_code self.error_code = error_code
self.error_string = _ERROR_NAME[error_code] self.error_string = C.ERROR_NAME[error_code]
self.data = data self.data = data

View File

@ -4,11 +4,12 @@
import logging import logging
import threading import threading
from time import sleep from time import sleep as _sleep
from binascii import hexlify # from binascii import hexlify as _hexlify
from . import base from . import base as _base
from .exceptions import * from . import exceptions as E
# from . import unhandled as _unhandled
_LOG_LEVEL = 6 _LOG_LEVEL = 6
@ -16,7 +17,7 @@ _l = logging.getLogger('logitech.unifying_receiver.listener')
_READ_EVENT_TIMEOUT = 90 # ms _READ_EVENT_TIMEOUT = 90 # ms
_IDLE_SLEEP = 900 # ms _IDLE_SLEEP = 950 # ms
class EventsListener(threading.Thread): class EventsListener(threading.Thread):
@ -34,6 +35,7 @@ class EventsListener(threading.Thread):
def __init__(self, receiver, events_callback): def __init__(self, receiver, events_callback):
super(EventsListener, self).__init__(name='Unifying_Receiver_Listener_' + hex(receiver)) super(EventsListener, self).__init__(name='Unifying_Receiver_Listener_' + hex(receiver))
self.daemon = True self.daemon = True
self.active = False
self.receiver = receiver self.receiver = receiver
self.callback = events_callback self.callback = events_callback
@ -45,13 +47,17 @@ class EventsListener(threading.Thread):
self.task_done = threading.Event() self.task_done = threading.Event()
def run(self): def run(self):
_l.log(_LOG_LEVEL, "(%d) starting", self.receiver)
self.active = True self.active = True
_l.log(_LOG_LEVEL, "(%d) starting", self.receiver)
# last_hook = _unhandled.hook
# _unhandled.hook = self.callback
while self.active: while self.active:
try: try:
# _l.log(_LOG_LEVEL, "(%d) reading next event", self.receiver) # _l.log(_LOG_LEVEL, "(%d) reading next event", self.receiver)
event = base.read(self.receiver, _READ_EVENT_TIMEOUT) event = _base.read(self.receiver, _READ_EVENT_TIMEOUT)
except NoReceiver: except E.NoReceiver:
_l.warn("(%d) receiver disconnected", self.receiver) _l.warn("(%d) receiver disconnected", self.receiver)
self.active = False self.active = False
break break
@ -62,11 +68,13 @@ class EventsListener(threading.Thread):
self.callback.__call__(*event) self.callback.__call__(*event)
elif self.task is None: elif self.task is None:
# _l.log(_LOG_LEVEL, "(%d) idle sleep", self.receiver) # _l.log(_LOG_LEVEL, "(%d) idle sleep", self.receiver)
sleep(_IDLE_SLEEP / 1000.0) _sleep(_IDLE_SLEEP / 1000.0)
else: else:
self.task_reply = self._make_request(*self.task) self.task_reply = self._make_request(*self.task)
self.task_done.set() self.task_done.set()
# _unhandled.hook = last_hook
def stop(self): def stop(self):
"""Tells the listener to stop as soon as possible.""" """Tells the listener to stop as soon as possible."""
_l.log(_LOG_LEVEL, "(%d) stopping", self.receiver) _l.log(_LOG_LEVEL, "(%d) stopping", self.receiver)
@ -88,7 +96,7 @@ class EventsListener(threading.Thread):
self.task = self.task_reply = None self.task = self.task_reply = None
self.task_processing.release() self.task_processing.release()
# _l.log(_LOG_LEVEL, "(%d) request '%s.%s' => [%s]", self.receiver, api_function.__module__, api_function.__name__, hexlify(reply)) # _l.log(_LOG_LEVEL, "(%d) request '%s.%s' => [%s]", self.receiver, api_function.__module__, api_function.__name__, _hexlify(reply))
if isinstance(reply, Exception): if isinstance(reply, Exception):
raise reply raise reply
return reply return reply
@ -97,7 +105,7 @@ class EventsListener(threading.Thread):
_l.log(_LOG_LEVEL, "(%d) calling '%s.%s' with %s, %s", self.receiver, api_function.__module__, api_function.__name__, args, kwargs) _l.log(_LOG_LEVEL, "(%d) calling '%s.%s' with %s, %s", self.receiver, api_function.__module__, api_function.__name__, args, kwargs)
try: try:
return api_function.__call__(self.receiver, *args, **kwargs) return api_function.__call__(self.receiver, *args, **kwargs)
except NoReceiver as nr: except E.NoReceiver as nr:
self.task_reply = nr self.task_reply = nr
self.active = False self.active = False
except Exception as e: except Exception as e:

View File

@ -5,7 +5,7 @@
import unittest import unittest
import struct import struct
from logitech.unifying_receiver.constants import * from ..constants import *
class Test_UR_Constants(unittest.TestCase): class Test_UR_Constants(unittest.TestCase):

View File

@ -5,10 +5,10 @@
import unittest import unittest
from binascii import hexlify from binascii import hexlify
from logitech.unifying_receiver import base from .. import base
from logitech.unifying_receiver.exceptions import * from ..exceptions import *
from logitech.unifying_receiver.constants import * from ..constants import *
from logitech.unifying_receiver.unhandled import * from .. import unhandled
class Test_UR_Base(unittest.TestCase): class Test_UR_Base(unittest.TestCase):
@ -157,15 +157,15 @@ class Test_UR_Base(unittest.TestCase):
global received_unhandled global received_unhandled
received_unhandled = (code, device, data) received_unhandled = (code, device, data)
# set_unhandled_hook(_unhandled) unhandled.hook = _unhandled
base.write(self.handle, self.device, FEATURE.ROOT + FEATURE.FEATURE_SET) base.write(self.handle, self.device, FEATURE.ROOT + FEATURE.FEATURE_SET)
reply = base.request(self.handle, self.device, fs_index + b'\x00') reply = base.request(self.handle, self.device, fs_index + b'\x00')
self.assertIsNotNone(reply, "request returned None reply") self.assertIsNotNone(reply, "request returned None reply")
self.assertNotEquals(reply[:1], b'\x00') self.assertNotEquals(reply[:1], b'\x00')
# self.assertIsNotNone(received_unhandled, "extra message not received by unhandled hook") self.assertIsNotNone(received_unhandled, "extra message not received by unhandled hook")
received_unhandled = None received_unhandled = None
# set_unhandled_hook() unhandled.hook = None
base.write(self.handle, self.device, FEATURE.ROOT + FEATURE.FEATURE_SET) base.write(self.handle, self.device, FEATURE.ROOT + FEATURE.FEATURE_SET)
reply = base.request(self.handle, self.device, fs_index + b'\x00') reply = base.request(self.handle, self.device, fs_index + b'\x00')
self.assertIsNotNone(reply, "request returned None reply") self.assertIsNotNone(reply, "request returned None reply")

View File

@ -5,9 +5,9 @@
import unittest import unittest
import warnings import warnings
from logitech.unifying_receiver import api from .. import api
from logitech.unifying_receiver.exceptions import * from ..constants import *
from logitech.unifying_receiver.constants import * from ..common import *
class Test_UR_API(unittest.TestCase): class Test_UR_API(unittest.TestCase):
@ -45,7 +45,7 @@ class Test_UR_API(unittest.TestCase):
devices = [] devices = []
for device in range(1, 1 + api.base.MAX_ATTACHED_DEVICES): for device in range(1, 1 + api._base.MAX_ATTACHED_DEVICES):
ok = api.ping(self.handle, device) ok = api.ping(self.handle, device)
self.assertIsNotNone(ok, "invalid ping reply") self.assertIsNotNone(ok, "invalid ping reply")
if ok: if ok:
@ -98,7 +98,7 @@ class Test_UR_API(unittest.TestCase):
self.assertIsNotNone(d_firmware, "failed to get device firmware") self.assertIsNotNone(d_firmware, "failed to get device firmware")
self.assertGreater(len(d_firmware), 0, "device reported no firmware") self.assertGreater(len(d_firmware), 0, "device reported no firmware")
for fw in d_firmware: for fw in d_firmware:
self.assertIsInstance(fw, api.FirmwareInfo) self.assertIsInstance(fw, FirmwareInfo)
def test_52_get_device_type(self): def test_52_get_device_type(self):
if self.handle is None: if self.handle is None:
@ -134,7 +134,7 @@ class Test_UR_API(unittest.TestCase):
device_info = api.get_device_info(self.handle, self.device, features_array=self.features_array) device_info = api.get_device_info(self.handle, self.device, features_array=self.features_array)
self.assertIsNotNone(device_info, "failed to read full device info") self.assertIsNotNone(device_info, "failed to read full device info")
self.assertIsInstance(device_info, api.AttachedDeviceInfo) self.assertIsInstance(device_info, AttachedDeviceInfo)
Test_UR_API.device_info = device_info Test_UR_API.device_info = device_info
def test_60_get_battery_level(self): def test_60_get_battery_level(self):
@ -160,7 +160,7 @@ class Test_UR_API(unittest.TestCase):
if all_devices: if all_devices:
self.assertIsNotNone(self.device) self.assertIsNotNone(self.device)
for device_info in all_devices: for device_info in all_devices:
self.assertIsInstance(device_info, api.AttachedDeviceInfo) self.assertIsInstance(device_info, AttachedDeviceInfo)
else: else:
self.assertIsNone(self.device) self.assertIsNone(self.device)

View File

@ -4,39 +4,33 @@
# #
import logging import logging
from binascii import hexlify from binascii import hexlify as _hexlify
_l = logging.getLogger('logitech.unifying_receiver.unhandled')
def _logging_unhandled_hook(reply_code, device, data): def _logdebug_hook(reply_code, device, data):
"""Default unhandled hook, logs the reply as DEBUG.""" """Default unhandled hook, logs the reply as DEBUG."""
_l.debug("UNHANDLED (,%d) code 0x%02x data [%s]", device, reply_code, hexlify(data)) _l = logging.getLogger('logitech.unifying_receiver.unhandled')
_l.debug("UNHANDLED (,%d) code 0x%02x data [%s]", device, reply_code, _hexlify(data))
_unhandled_hook = _logging_unhandled_hook """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, device number, 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.
"""
hook = _logdebug_hook
def _publish(reply_code, device, data): def _publish(reply_code, device, data):
"""Delivers a reply to the unhandled hook, if any.""" """Delivers a reply to the unhandled hook, if any."""
if _unhandled_hook is not None: if hook is not None:
_unhandled_hook.__call__(reply_code, device, data) hook.__call__(reply_code, device, data)
def set_unhandled_hook(hook=None):
"""Sets 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, device number, 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.
"""
global _unhandled_hook
_unhandled_hook = hook

5
solaar
View File

@ -2,7 +2,8 @@
cd `dirname "$0"` cd `dirname "$0"`
export LD_LIBRARY_PATH=$PWD/lib export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:$PWD/lib
export PYTHONPATH=$PWD/lib export PYTHONPATH=$PWD:$PWD/lib
exec python -OO solaar.py "$@" exec python -OO solaar.py "$@"
#exec python -OO -m profile -o $TMPDIR/profile.log solaar.py "$@"

279
solaar.py
View File

@ -1,267 +1,13 @@
#!/usr/bin/env python #!/usr/bin/env python
__version__ = '0.3' __version__ = '0.4'
# #
# #
# #
import logging import logging
import time import os.path
import threading
from gi.repository import GObject
from gi.repository import Gtk
from logitech.unifying_receiver import api as ur
from logitech.unifying_receiver.listener import EventsListener
from logitech.devices import *
#
# A few constants
#
APP_TITLE = 'Solaar'
UNIFYING_RECEIVER = 'Unifying Receiver'
NO_DEVICES = 'No devices attached.'
NO_RECEIVER = 'Unifying Receiver not found.'
FOUND_RECEIVER = 'Unifying Receiver found.'
STATUS_TIMEOUT = 61 # seconds
ICON_UPDATE_SLEEP = 7 # seconds
#
# Optional desktop notifications.
#
try:
import notify2
notify2.init(APP_TITLE)
_notifications = {}
_ICONS = {}
import os.path
def notify_desktop(status, title, text, icon=None):
logging.debug("notify_desktop [%d] %s: %s", status, title, text)
def _icon_path(name):
path = os.path.join(__file__, '..', 'images', name + '.png')
path = os.path.abspath(os.path.normpath(path))
return path if os.path.isfile(path) else None
if icon is None:
icon = title
if icon in _ICONS:
path = _ICONS[icon]
else:
_ICONS[icon] = path = _icon_path(icon)
if path:
icon = path
if icon is None:
icon = 'error' if status < 0 else 'info'
if title in _notifications:
notification = _notifications[title]
else:
_notifications[title] = notification = notify2.Notification(title, icon=icon)
notification.set_category(APP_TITLE)
notification.set_urgency(notify2.URGENCY_CRITICAL if status < 0 else notify2.URGENCY_NORMAL)
notification.update(title, text, icon)
notification.show()
def clear_notifications():
all(n.close() for n in list(_notifications.values()))
notify2.uninit()
_notifications.clear()
_ICONS.clear()
except ImportError:
logging.warn("python-notify2 not found, desktop notifications are disabled")
def notify_desktop(status, title, text, icon=None): pass
def clear_notifications(): pass
#
#
#
class StatusThread(threading.Thread):
def __init__(self, status_icon):
super(StatusThread, self).__init__(name='StatusThread')
self.daemon = True
self.status_icon = status_icon
self.last_receiver_status = None
self.listener = None
self.devices = {}
self.statuses = {}
def run(self):
self.active = True
while self.active:
if self.listener is None:
receiver = ur.open()
if receiver:
for devinfo in ur.list_devices(receiver):
self.devices[devinfo.number] = devinfo
self.listener = EventsListener(receiver, self.events_callback)
self.listener.start()
logging.info(str(self.listener))
notify_desktop(1, UNIFYING_RECEIVER, FOUND_RECEIVER)
self.last_receiver_status = 1
else:
if self.last_receiver_status != -1:
notify_desktop(-1, UNIFYING_RECEIVER, NO_RECEIVER)
self.last_receiver_status = -1
elif not self.listener.active:
logging.info(str(self.listener))
self.listener = None
self.devices.clear()
self.statuses.clear()
notify_desktop(-1, UNIFYING_RECEIVER, NO_RECEIVER)
self.last_receiver_status = -1
if self.active:
update_icon = True
if self.listener and self.devices:
update_icon &= self.update_old_statuses()
if self.active:
if update_icon:
GObject.idle_add(self.update_status_icon)
time.sleep(ICON_UPDATE_SLEEP)
def stop(self):
self.active = False
if self.listener:
self.listener.stop()
ur.close(self.listener.receiver)
def update_old_statuses(self):
updated = False
for devinfo in list(self.devices.values()):
if devinfo.number in self.statuses:
last_status_time = self.statuses[devinfo.number][0]
if time.time() - last_status_time > STATUS_TIMEOUT:
status = request_status(devinfo, self.listener)
updated |= self.device_status_changed(devinfo, status)
else:
self.statuses[devinfo.number] = [0, None, None]
updated |= self.device_status_changed(devinfo, (DEVICE_STATUS.CONNECTED, None))
return updated
def events_callback(self, code, device, data):
updated = False
if device in self.devices:
devinfo = self.devices[device]
if code == 0x10 and data[0] == 'b\x8F':
updated = True
self.device_status_changed(devinfo, DEVICE_STATUS.UNAVAILABLE)
elif code == 0x11:
status = process_event(devinfo, self.listener, data)
updated |= self.device_status_changed(devinfo, status)
else:
logging.warn("unknown event code %02x", code)
elif device:
logging.debug("got event (%d, %d, %s) for new device", code, device, data)
devinfo = ur.get_device_info(self.listener.receiver, device)
if devinfo:
self.devices[device] = devinfo
self.statuses[device] = [0, None, None]
updated |= self.device_status_changed(devinfo, (DEVICE_STATUS.CONNECTED, None))
# else:
# logging.warn("got event (%d, %d, %s) for unknown device", code, device, data)
else:
logging.warn("don't know how to handle event (%d, %d, %s)", code, device, data)
if updated:
GObject.idle_add(self.update_status_icon)
def device_status_changed(self, devinfo, status):
if status is None or devinfo.number not in self.statuses:
return False
if type(status) == int:
status_code = status
status_text = DEVICE_STATUS_NAME[status_code]
else:
status_code = status[0]
status_text = DEVICE_STATUS_NAME[status_code] if status[1] is None else status[1]
device_status = self.statuses[devinfo.number]
old_status_code = device_status[1]
device_status[0] = time.time()
device_status[1] = status_code
device_status[2] = status_text
if old_status_code == status_code:
# the device has been missing twice in a row (1 to 2 minutes), so
# forget about it
if status_code == DEVICE_STATUS.UNAVAILABLE:
del self.devices[devinfo.number]
del self.statuses[devinfo.number]
else:
logging.debug("device status changed from %s => %s: %s", old_status_code, status_code, status_text)
notify_desktop(status_code, devinfo.name, status_text)
return True
def update_status_icon(self):
if self.listener and self.listener.active:
if self.devices:
all_statuses = []
for d in self.devices:
devinfo = self.devices[d]
status_text = self.statuses[d][2]
if status_text:
if ' ' in status_text:
all_statuses.append(devinfo.name)
all_statuses.append(' ' + status_text)
else:
all_statuses.append(devinfo.name + ' ' + status_text)
else:
all_statuses.append(devinfo.name)
tooltip = '\n'.join(all_statuses)
else:
tooltip = NO_DEVICES
else:
tooltip = NO_RECEIVER
self.status_icon.set_tooltip_text(tooltip)
def activate_icon(self, _):
if self.listener and self.listener.active:
if self.devices:
for devinfo in list(self.devices.values()):
_, status_code, status_text = self.statuses[devinfo.number]
notify_desktop(status_code, devinfo.name, status_text)
else:
notify_desktop(1, UNIFYING_RECEIVER, NO_DEVICES)
self.last_receiver_status = 1
else:
notify_desktop(-1, UNIFYING_RECEIVER, NO_RECEIVER)
self.last_receiver_status = -1
def show_icon_menu(icon, button, time, status_thread):
menu = Gtk.Menu()
status_item = Gtk.MenuItem('Status')
status_item.connect('activate', status_thread.activate_icon)
menu.append(status_item)
quit_item = Gtk.MenuItem('Quit')
quit_item.connect('activate', Gtk.main_quit)
menu.append(quit_item)
menu.show_all()
menu.popup(None, None, icon.position_menu, icon, button, time)
if __name__ == '__main__': if __name__ == '__main__':
@ -272,21 +18,10 @@ if __name__ == '__main__':
args = arg_parser.parse_args() args = arg_parser.parse_args()
log_level = logging.root.level - 10 * args.verbose log_level = logging.root.level - 10 * args.verbose
logging.root.setLevel(log_level if log_level > 0 else 1) logging.basicConfig(level=log_level if log_level > 0 else 1)
status_icon = Gtk.StatusIcon.new_from_file('images/' + UNIFYING_RECEIVER + '.png') images_path = os.path.join(__file__, '..', 'images')
status_icon.set_title(APP_TITLE) images_path = os.path.abspath(os.path.normpath(images_path))
status_icon.set_name(APP_TITLE)
status_icon.set_tooltip_text('Searching...')
notify_desktop(0, UNIFYING_RECEIVER, 'Searching...')
GObject.threads_init() import app
status_thread = StatusThread(status_icon) app.run(images_path)
status_thread.start()
status_icon.connect('popup_menu', show_icon_menu, status_thread)
status_icon.connect('activate', status_thread.activate_icon)
Gtk.main()
status_thread.stop()
clear_notifications()