reworked the receiver and devices into classes
This commit is contained in:
parent
5985105e0e
commit
f2dac70131
4
README
4
README
|
@ -17,10 +17,10 @@ Requirements
|
|||
------------
|
||||
|
||||
- Python (2.7 or 3.2).
|
||||
- Gtk 3 (preferred), though Gtk 2 should work with minor problems.
|
||||
- Gtk 3; Gtk 2 should partially work with some problems.
|
||||
- Python GI (GObject Introspection), for Gtk bindings.
|
||||
- Optional libnotify GI bindings, for desktop notifications.
|
||||
- A hidapi native implementation (see the INSTALL file for details).
|
||||
- Optional python-notify2 for desktop notifications.
|
||||
|
||||
|
||||
Thanks
|
||||
|
|
|
@ -0,0 +1,288 @@
|
|||
#
|
||||
#
|
||||
#
|
||||
|
||||
from logging import getLogger as _Logger
|
||||
_LOG_LEVEL = 6
|
||||
|
||||
from threading import Event as _Event
|
||||
|
||||
from logitech.unifying_receiver import base as _base
|
||||
from logitech.unifying_receiver import api as _api
|
||||
from logitech.unifying_receiver import listener as _listener
|
||||
from logitech import devices as _devices
|
||||
from logitech.devices.constants import (STATUS, STATUS_NAME, PROPS, NAMES)
|
||||
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
class DeviceInfo(object):
|
||||
"""A device attached to the receiver.
|
||||
"""
|
||||
def __init__(self, receiver, number, status=STATUS.UNKNOWN):
|
||||
self.LOG = _Logger("Device-%d" % number)
|
||||
self.receiver = receiver
|
||||
self.number = number
|
||||
self._name = None
|
||||
self._kind = None
|
||||
self._firmware = None
|
||||
self._features = None
|
||||
|
||||
self._status = status
|
||||
self.props = {}
|
||||
|
||||
@property
|
||||
def handle(self):
|
||||
return self.receiver.handle
|
||||
|
||||
@property
|
||||
def status(self):
|
||||
return self._status
|
||||
|
||||
@status.setter
|
||||
def status(self, new_status):
|
||||
if new_status != self._status and not (new_status == STATUS.CONNECTED and self._status > new_status):
|
||||
self.LOG.debug("status %d => %d", self._status, new_status)
|
||||
urgent = new_status < STATUS.CONNECTED or self._status < STATUS.CONNECTED
|
||||
self._status = new_status
|
||||
self.receiver._device_changed(self, urgent)
|
||||
|
||||
@property
|
||||
def status_text(self):
|
||||
if self._status < STATUS.CONNECTED:
|
||||
return STATUS_NAME[self._status]
|
||||
|
||||
t = []
|
||||
if self.props.get(PROPS.BATTERY_LEVEL):
|
||||
t.append('Battery: %d%%' % self.props[PROPS.BATTERY_LEVEL])
|
||||
if self.props.get(PROPS.BATTERY_STATUS):
|
||||
t.append(self.props[PROPS.BATTERY_STATUS])
|
||||
if self.props.get(PROPS.LIGHT_LEVEL):
|
||||
t.append('Light: %d lux' % self.props[PROPS.LIGHT_LEVEL])
|
||||
return ', '.join(t) if t else STATUS_NAME[STATUS.CONNECTED]
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
if self._name is None:
|
||||
if self._status >= STATUS.CONNECTED:
|
||||
self._name = self.receiver.request(_api.get_device_name, self.number, self.features)
|
||||
return self._name or '?'
|
||||
|
||||
@property
|
||||
def device_name(self):
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def kind(self):
|
||||
if self._kind is None:
|
||||
if self._status >= STATUS.CONNECTED:
|
||||
self._kind = self.receiver.request(_api.get_device_kind, self.number, self.features)
|
||||
return self._kind or '?'
|
||||
|
||||
@property
|
||||
def firmware(self):
|
||||
if self._firmware is None:
|
||||
if self._status >= STATUS.CONNECTED:
|
||||
self._firmware = self.receiver.request(_api.get_device_firmware, self.number, self.features)
|
||||
return self._firmware or ()
|
||||
|
||||
@property
|
||||
def features(self):
|
||||
if self._features is None:
|
||||
if self._status >= STATUS.CONNECTED:
|
||||
self._features = self.receiver.request(_api.get_device_features, self.number)
|
||||
return self._features or ()
|
||||
|
||||
def ping(self):
|
||||
return self.receiver.request(_api.ping, self.number)
|
||||
|
||||
def process_event(self, code, data):
|
||||
if code == 0x10 and data[:1] == b'\x8F':
|
||||
self.status = STATUS.UNAVAILABLE
|
||||
return True
|
||||
|
||||
if code == 0x11:
|
||||
status = _devices.process_event(self, data, self.receiver)
|
||||
if status:
|
||||
if type(status) == int:
|
||||
self.status = status
|
||||
return True
|
||||
|
||||
if type(status) == tuple:
|
||||
p = dict(self.props)
|
||||
self.props.update(status[1])
|
||||
if self.status == status[0]:
|
||||
if p != self.props:
|
||||
self.receiver._device_changed(self)
|
||||
else:
|
||||
self.status = status[0]
|
||||
return True
|
||||
|
||||
self.LOG.warn("don't know how to handle status %s", status)
|
||||
|
||||
return False
|
||||
|
||||
def __hash__(self):
|
||||
return self.number
|
||||
|
||||
def __str__(self):
|
||||
return 'DeviceInfo(%d,%s,%d)' % (self.number, self.name, self._status)
|
||||
|
||||
def __repr__(self):
|
||||
return '<DeviceInfo(number=%d,name=%s,status=%d)>' % (self.number, self.name, self._status)
|
||||
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
class Receiver(_listener.EventsListener):
|
||||
"""Keeps the status of a Unifying Receiver.
|
||||
"""
|
||||
NAME = kind = 'Unifying Receiver'
|
||||
max_devices = _api.MAX_ATTACHED_DEVICES
|
||||
|
||||
def __init__(self, path, handle):
|
||||
super(Receiver, self).__init__(handle, self._events_handler)
|
||||
self.path = path
|
||||
|
||||
self._status = STATUS.BOOTING
|
||||
self.status_changed = _Event()
|
||||
self.status_changed.urgent = False
|
||||
self.status_changed.reason = None
|
||||
|
||||
self.LOG = _Logger("Receiver-%s" % path)
|
||||
self.LOG.info("initializing")
|
||||
|
||||
self.devices = {}
|
||||
self.events_handler = None
|
||||
|
||||
init = (_base.request(handle, 0xFF, b'\x81\x00') and
|
||||
_base.request(handle, 0xFF, b'\x80\x00', b'\x00\x01') and
|
||||
_base.request(handle, 0xFF, b'\x81\x02'))
|
||||
if init:
|
||||
self.LOG.info("initialized")
|
||||
else:
|
||||
self.LOG.warn("initialization failed")
|
||||
|
||||
if _base.request(handle, 0xFF, b'\x80\x02', b'\x02'):
|
||||
self.LOG.info("triggered device events")
|
||||
else:
|
||||
self.LOG.warn("failed to trigger device events")
|
||||
|
||||
def close(self):
|
||||
"""Closes the receiver's handle.
|
||||
|
||||
The receiver can no longer be used in API calls after this.
|
||||
"""
|
||||
self.LOG.info("closing")
|
||||
self.stop()
|
||||
|
||||
@property
|
||||
def status(self):
|
||||
return self._status
|
||||
|
||||
@status.setter
|
||||
def status(self, new_status):
|
||||
if new_status != self._status:
|
||||
self.LOG.debug("status %d => %d", self._status, new_status)
|
||||
self._status = new_status
|
||||
self.status_changed.reason = self
|
||||
self.status_changed.urgent = True
|
||||
self.status_changed.set()
|
||||
|
||||
@property
|
||||
def status_text(self):
|
||||
status = self._status
|
||||
if status == STATUS.UNKNOWN:
|
||||
return 'Initializing...'
|
||||
if status == STATUS.UNAVAILABLE:
|
||||
return 'Receiver not found.'
|
||||
if status == STATUS.BOOTING:
|
||||
return 'Scanning...'
|
||||
if status == STATUS.CONNECTED:
|
||||
return 'No devices found.'
|
||||
if len(self.devices) > 1:
|
||||
return '%d devices found' % len(self.devices)
|
||||
return '1 device found'
|
||||
|
||||
@property
|
||||
def device_name(self):
|
||||
return self.NAME
|
||||
|
||||
def _device_changed(self, dev, urgent=False):
|
||||
self.status_changed.reason = dev
|
||||
self.status_changed.urgent = urgent
|
||||
self.status_changed.set()
|
||||
|
||||
def _events_handler(self, event):
|
||||
if event.code == 0x10 and event.data[0:2] == b'\x41\x04':
|
||||
state_code = ord(event.data[2:3]) & 0xF0
|
||||
state = STATUS.UNAVAILABLE if state_code == 0x60 else \
|
||||
STATUS.CONNECTED if state_code == 0xA0 else \
|
||||
STATUS.CONNECTED if state_code == 0x20 else \
|
||||
None
|
||||
if state is None:
|
||||
self.LOG.warn("don't know how to handle status 0x%02x: %s", state_code, event)
|
||||
return
|
||||
|
||||
if event.devnumber in self.devices:
|
||||
self.devices[event.devnumber].status = state
|
||||
return
|
||||
|
||||
if event.devnumber < 1 or event.devnumber > self.max_devices:
|
||||
self.LOG.warn("got event for invalid device number %d: %s", event.devnumber, event)
|
||||
return
|
||||
|
||||
dev = DeviceInfo(self, event.devnumber, state)
|
||||
if state == STATUS.CONNECTED:
|
||||
n, k = dev.name, dev.kind
|
||||
else:
|
||||
# we can query the receiver for the device short name
|
||||
dev_id = self.request(_base.request, 0xFF, b'\x83\xB5', event.data[4:5])
|
||||
if dev_id:
|
||||
shortname = str(dev_id[2:].rstrip(b'\x00'))
|
||||
if shortname in NAMES:
|
||||
dev._name, dev._kind = NAMES[shortname]
|
||||
else:
|
||||
self.LOG.warn("could not properly detect inactive device %d: %s", event.devnumber, shortname)
|
||||
self.devices[event.devnumber] = dev
|
||||
self.LOG.info("new device ready %s", dev)
|
||||
self.status = STATUS.CONNECTED + len(self.devices)
|
||||
return
|
||||
|
||||
if event.devnumber == 0xFF:
|
||||
if event.code == 0xFF and event.data is None:
|
||||
# receiver disconnected
|
||||
self.LOG.info("disconnected")
|
||||
self.devices = {}
|
||||
self.status = STATUS.UNAVAILABLE
|
||||
return
|
||||
self.LOG.warn("don't know how to handle event %s", event)
|
||||
elif event.devnumber in self.devices:
|
||||
dev = self.devices[event.devnumber]
|
||||
if dev.process_event(event.code, event.data):
|
||||
return
|
||||
|
||||
if self.events_handler:
|
||||
self.events_handler(event)
|
||||
|
||||
def __str__(self):
|
||||
return 'Receiver(%s,%x,%d:%d)' % (self.path, self._handle, self._active, self._status)
|
||||
|
||||
@classmethod
|
||||
def open(self):
|
||||
"""Opens the first Logitech Unifying Receiver found attached to the machine.
|
||||
|
||||
:returns: An open file handle for the found receiver, or ``None``.
|
||||
"""
|
||||
for rawdevice in _base.list_receiver_devices():
|
||||
_Logger("receiver").log(_LOG_LEVEL, "checking %s", rawdevice)
|
||||
|
||||
handle = _base.try_open(rawdevice.path)
|
||||
if handle:
|
||||
receiver = Receiver(rawdevice.path, handle)
|
||||
receiver.start()
|
||||
return receiver
|
||||
|
||||
return None
|
130
app/solaar.py
130
app/solaar.py
|
@ -1,105 +1,91 @@
|
|||
#!/usr/bin/env python
|
||||
|
||||
__version__ = '0.4'
|
||||
__version__ = '0.5'
|
||||
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
import logging
|
||||
|
||||
# from gi import pygtkcompat
|
||||
# pygtkcompat.enable_gtk()
|
||||
from gi.repository import (Gtk, GObject)
|
||||
|
||||
from logitech.devices import constants as C
|
||||
|
||||
import ui
|
||||
|
||||
|
||||
APP_TITLE = 'Solaar'
|
||||
|
||||
|
||||
def _status_check(watcher, tray_icon, window):
|
||||
last_text = None
|
||||
|
||||
while True:
|
||||
watcher.status_changed.wait()
|
||||
watcher.status_changed.clear()
|
||||
|
||||
if watcher.devices:
|
||||
lines = []
|
||||
if watcher.rstatus.code < C.STATUS.CONNECTED:
|
||||
lines += (watcher.rstatus.text, '')
|
||||
|
||||
devstatuses = [watcher.devices[d] for d in range(1, 1 + watcher.rstatus.max_devices) if d in watcher.devices]
|
||||
for devstatus in devstatuses:
|
||||
if devstatus.text:
|
||||
if ' ' in devstatus.text:
|
||||
lines += ('<b>' + devstatus.name + '</b>', ' ' + devstatus.text)
|
||||
else:
|
||||
lines.append('<b>' + devstatus.name + '</b> ' + devstatus.text)
|
||||
else:
|
||||
lines.append('<b>' + devstatus.name + '</b>')
|
||||
lines.append('')
|
||||
|
||||
text = '\n'.join(lines).rstrip('\n')
|
||||
else:
|
||||
text = watcher.rstatus.text
|
||||
|
||||
if text != last_text:
|
||||
last_text = text
|
||||
icon_name = APP_TITLE + '-fail' if watcher.rstatus.code < C.STATUS.CONNECTED else APP_TITLE
|
||||
|
||||
if tray_icon:
|
||||
GObject.idle_add(ui.icon.update, tray_icon, watcher.rstatus, text, icon_name)
|
||||
|
||||
if window:
|
||||
GObject.idle_add(ui.window.update, window, watcher.rstatus, watcher.devices, icon_name)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import argparse
|
||||
arg_parser = argparse.ArgumentParser(prog=APP_TITLE)
|
||||
arg_parser.add_argument('-v', '--verbose', action='count', default=0,
|
||||
arg_parser.add_argument('-v', '--verbose',
|
||||
action='count', default=0,
|
||||
help='increase the logger verbosity (may be repeated)')
|
||||
arg_parser.add_argument('-S', '--no-systray', action='store_false', dest='systray',
|
||||
arg_parser.add_argument('-S', '--no-systray',
|
||||
action='store_false',
|
||||
dest='systray',
|
||||
help='don\'t embed the application window into the systray')
|
||||
arg_parser.add_argument('-N', '--no-notifications', action='store_false', dest='notifications',
|
||||
arg_parser.add_argument('-N', '--no-notifications',
|
||||
action='store_false',
|
||||
dest='notifications',
|
||||
help='disable desktop notifications (shown only when in systray)')
|
||||
arg_parser.add_argument('-V', '--version', action='version', version='%(prog)s ' + __version__)
|
||||
arg_parser.add_argument('-V', '--version',
|
||||
action='version',
|
||||
version='%(prog)s ' + __version__)
|
||||
args = arg_parser.parse_args()
|
||||
|
||||
import logging
|
||||
log_level = logging.root.level - 10 * args.verbose
|
||||
log_format='%(asctime)s %(levelname)8s [%(threadName)s] %(name)s: %(message)s'
|
||||
logging.basicConfig(level=log_level if log_level > 0 else 1, format=log_format)
|
||||
log_format='%(asctime)s.%(msecs)03d %(levelname)8s [%(threadName)s] %(name)s: %(message)s'
|
||||
logging.basicConfig(level=log_level if log_level > 0 else 1, format=log_format, datefmt='%H:%M:%S')
|
||||
|
||||
from gi.repository import GObject
|
||||
GObject.threads_init()
|
||||
|
||||
import ui
|
||||
|
||||
args.notifications &= args.systray
|
||||
if args.notifications:
|
||||
ui.notify.init(APP_TITLE)
|
||||
args.notifications &= ui.notify.init(APP_TITLE)
|
||||
|
||||
from watcher import Watcher
|
||||
watcher = Watcher(APP_TITLE, ui.notify.show if args.notifications else None)
|
||||
watcher.start()
|
||||
import watcher
|
||||
tray_icon = None
|
||||
window = ui.window.create(APP_TITLE,
|
||||
watcher.DUMMY.NAME,
|
||||
watcher.DUMMY.max_devices,
|
||||
args.systray)
|
||||
window.set_icon_name(APP_TITLE + '-init')
|
||||
|
||||
window = ui.window.create(APP_TITLE, watcher.rstatus, args.systray)
|
||||
window.set_icon_name(APP_TITLE + '-fail')
|
||||
def _ui_update(receiver, tray_icon, window):
|
||||
icon_name = APP_TITLE + '-fail' if receiver.status < 1 else APP_TITLE
|
||||
if window:
|
||||
GObject.idle_add(ui.window.update, window, receiver, icon_name)
|
||||
if tray_icon:
|
||||
GObject.idle_add(ui.icon.update, tray_icon, receiver, icon_name)
|
||||
|
||||
def _notify(device):
|
||||
GObject.idle_add(ui.notify.show, device)
|
||||
|
||||
w = watcher.Watcher(APP_TITLE,
|
||||
lambda r: _ui_update(r, tray_icon, window),
|
||||
_notify if args.notifications else None)
|
||||
w.start()
|
||||
|
||||
if args.systray:
|
||||
tray_icon = ui.icon.create(APP_TITLE, (ui.window.toggle, window))
|
||||
tray_icon.set_from_icon_name(APP_TITLE + '-fail')
|
||||
def _toggle_notifications(item):
|
||||
# logging.debug("toggle notifications %s", item)
|
||||
if ui.notify.available:
|
||||
if item.get_active():
|
||||
ui.notify.init(APP_TITLE)
|
||||
else:
|
||||
ui.notify.uninit()
|
||||
item.set_sensitive(ui.notify.available)
|
||||
|
||||
menu = (
|
||||
('Notifications', _toggle_notifications if args.notifications else None, args.notifications),
|
||||
)
|
||||
|
||||
tray_icon = ui.icon.create(APP_TITLE, (ui.window.toggle, window), menu)
|
||||
tray_icon.set_from_icon_name(APP_TITLE + '-init')
|
||||
else:
|
||||
tray_icon = None
|
||||
window.present()
|
||||
|
||||
from threading import Thread
|
||||
status_check = Thread(group=APP_TITLE, name='StatusCheck', target=_status_check, args=(watcher, tray_icon, window))
|
||||
status_check.daemon = True
|
||||
status_check.start()
|
||||
|
||||
from gi.repository import Gtk
|
||||
Gtk.main()
|
||||
|
||||
watcher.stop()
|
||||
ui.notify.set_active(False)
|
||||
w.stop()
|
||||
ui.notify.uninit()
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
from gi.repository import Gtk
|
||||
|
||||
|
||||
def create(title, click_action=None):
|
||||
def create(title, click_action=None, actions=None):
|
||||
icon = Gtk.StatusIcon()
|
||||
icon.set_title(title)
|
||||
icon.set_name(title)
|
||||
|
@ -19,9 +19,30 @@ def create(title, click_action=None):
|
|||
icon.connect('activate', click_action)
|
||||
|
||||
menu = Gtk.Menu()
|
||||
item = Gtk.MenuItem('Quit')
|
||||
item.connect('activate', Gtk.main_quit)
|
||||
menu.append(item)
|
||||
|
||||
if actions:
|
||||
for name, activate, checked in actions:
|
||||
if checked is None:
|
||||
item = Gtk.MenuItem(name)
|
||||
if activate is None:
|
||||
item.set_sensitive(False)
|
||||
else:
|
||||
item.connect('activate', activate)
|
||||
else:
|
||||
item = Gtk.CheckMenuItem(name)
|
||||
if activate is None:
|
||||
item.set_sensitive(False)
|
||||
else:
|
||||
item.set_active(checked or False)
|
||||
item.connect('toggled', activate)
|
||||
|
||||
menu.append(item)
|
||||
menu.append(Gtk.SeparatorMenuItem())
|
||||
|
||||
quit_item = Gtk.MenuItem('Quit')
|
||||
quit_item.connect('activate', Gtk.main_quit)
|
||||
menu.append(quit_item)
|
||||
|
||||
menu.show_all()
|
||||
|
||||
icon.connect('popup_menu',
|
||||
|
@ -32,8 +53,27 @@ def create(title, click_action=None):
|
|||
return icon
|
||||
|
||||
|
||||
def update(icon, receiver, tooltip=None, icon_name=None):
|
||||
if tooltip is not None:
|
||||
icon.set_tooltip_markup(tooltip)
|
||||
def update(icon, receiver, icon_name=None):
|
||||
if icon_name is not None:
|
||||
icon.set_from_icon_name(icon_name)
|
||||
|
||||
if receiver.devices:
|
||||
lines = []
|
||||
if receiver.status < 1:
|
||||
lines += (receiver.status_text, '')
|
||||
|
||||
devlist = [receiver.devices[d] for d in range(1, 1 + receiver.max_devices) if d in receiver.devices]
|
||||
for dev in devlist:
|
||||
name = '<b>' + dev.name + '</b>'
|
||||
if dev.status < 1:
|
||||
lines.append(name + ' (' + dev.status_text + ')')
|
||||
else:
|
||||
lines.append(name)
|
||||
if dev.status > 1:
|
||||
lines.append(' ' + dev.status_text)
|
||||
lines.append('')
|
||||
|
||||
text = '\n'.join(lines).rstrip('\n')
|
||||
icon.set_tooltip_markup(text)
|
||||
else:
|
||||
icon.set_tooltip_text(receiver.status_text)
|
||||
|
|
107
app/ui/notify.py
107
app/ui/notify.py
|
@ -6,83 +6,72 @@ import logging
|
|||
|
||||
|
||||
try:
|
||||
import notify2 as _notify
|
||||
from time import time as timestamp
|
||||
from gi.repository import Notify
|
||||
from gi.repository import Gtk
|
||||
|
||||
available = True # assumed to be working since the import succeeded
|
||||
_active = False # not yet active
|
||||
_app_title = None
|
||||
from logitech.devices.constants import STATUS
|
||||
|
||||
# necessary because the notifications daemon does not know about our XDG_DATA_DIRS
|
||||
theme = Gtk.IconTheme.get_default()
|
||||
_icons = {}
|
||||
|
||||
def _icon(title):
|
||||
if title not in _icons:
|
||||
icon = theme.lookup_icon(title, 0, 0)
|
||||
_icons[title] = icon.get_filename() if icon else None
|
||||
|
||||
return _icons.get(title)
|
||||
|
||||
# assumed to be working since the import succeeded
|
||||
available = True
|
||||
|
||||
_TIMEOUT = 5 * 60 # after this many seconds assume the notification object is no longer valid
|
||||
_notifications = {}
|
||||
|
||||
|
||||
def init(app_title, active=True):
|
||||
def init(app_title=None):
|
||||
"""Init the notifications system."""
|
||||
global _app_title
|
||||
_app_title = app_title
|
||||
return set_active(active)
|
||||
|
||||
|
||||
def set_active(active=True):
|
||||
global available, _active
|
||||
global available
|
||||
if available:
|
||||
if active:
|
||||
if not _active:
|
||||
try:
|
||||
_notify.init(_app_title)
|
||||
_active = True
|
||||
except:
|
||||
logging.exception("initializing desktop notifications")
|
||||
available = False
|
||||
else:
|
||||
if _active:
|
||||
for n in _notifications.values():
|
||||
try:
|
||||
n.close()
|
||||
except:
|
||||
logging.exception("closing notification %s", n)
|
||||
try:
|
||||
_notify.uninit()
|
||||
except:
|
||||
logging.exception("stopping desktop notifications")
|
||||
available = False
|
||||
_active = False
|
||||
return _active
|
||||
logging.info("starting desktop notifications")
|
||||
if not Notify.is_initted():
|
||||
try:
|
||||
return Notify.init(app_title or Notify.get_app_name())
|
||||
except:
|
||||
logging.exception("initializing desktop notifications")
|
||||
available = False
|
||||
return available and Notify.is_initted()
|
||||
|
||||
|
||||
def active():
|
||||
return _active
|
||||
def uninit():
|
||||
if available and Notify.is_initted():
|
||||
logging.info("stopping desktop notifications")
|
||||
_notifications.clear()
|
||||
Notify.uninit()
|
||||
|
||||
|
||||
def show(status_code, title, text='', icon=None):
|
||||
def show(dev):
|
||||
"""Show a notification with title and text."""
|
||||
if available and _active:
|
||||
n = None
|
||||
if title in _notifications:
|
||||
n = _notifications[title]
|
||||
if timestamp() - n.timestamp > _TIMEOUT:
|
||||
del _notifications[title]
|
||||
n = None
|
||||
if available and Notify.is_initted():
|
||||
summary = dev.device_name
|
||||
|
||||
# if a notification with same name is already visible, reuse it to avoid spamming
|
||||
n = _notifications.get(summary)
|
||||
if n is None:
|
||||
n = _notify.Notification(title)
|
||||
_notifications[title] = n
|
||||
n = _notifications[summary] = Notify.Notification()
|
||||
|
||||
n.update(summary, dev.status_text, _icon(summary) or dev.kind)
|
||||
urgency = Notify.Urgency.LOW if dev.status > STATUS.CONNECTED else Notify.Urgency.NORMAL
|
||||
n.set_urgency(urgency)
|
||||
|
||||
n.update(title, text, icon or title)
|
||||
n.timestamp = timestamp()
|
||||
try:
|
||||
# logging.debug("showing notification %s", n)
|
||||
# logging.debug("showing %s", n)
|
||||
n.show()
|
||||
except Exception:
|
||||
logging.exception("showing notification %s", n)
|
||||
|
||||
logging.exception("showing %s", n)
|
||||
|
||||
except ImportError:
|
||||
logging.warn("python-notify2 not found, desktop notifications are disabled")
|
||||
logging.warn("Notify not found in gi.repository, desktop notifications are disabled")
|
||||
available = False
|
||||
active = False
|
||||
def init(app_title, active=True): return False
|
||||
def active(): return False
|
||||
def set_active(active=True): return False
|
||||
def show(status_code, title, text, icon=None): pass
|
||||
init = lambda app_title: False
|
||||
uninit = lambda: None
|
||||
show = lambda status_code, title, text: None
|
||||
|
|
103
app/ui/window.py
103
app/ui/window.py
|
@ -4,7 +4,7 @@
|
|||
|
||||
from gi.repository import (Gtk, Gdk)
|
||||
|
||||
from logitech.devices import constants as C
|
||||
from logitech.devices.constants import (STATUS, PROPS)
|
||||
|
||||
|
||||
_SMALL_DEVICE_ICON_SIZE = Gtk.IconSize.BUTTON
|
||||
|
@ -40,14 +40,14 @@ def _find_children(container, *child_names):
|
|||
return result if count > 1 else result[0]
|
||||
|
||||
|
||||
def _update_receiver_box(box, rstatus):
|
||||
def _update_receiver_box(box, receiver):
|
||||
label, buttons = _find_children(box, 'label', 'buttons')
|
||||
label.set_text(rstatus.text or '')
|
||||
buttons.set_visible(rstatus.code >= C.STATUS.CONNECTED)
|
||||
label.set_text(receiver.status_text or '')
|
||||
buttons.set_visible(receiver.status >= STATUS.CONNECTED)
|
||||
|
||||
|
||||
def _update_device_box(frame, devstatus):
|
||||
if devstatus is None:
|
||||
def _update_device_box(frame, dev):
|
||||
if dev is None:
|
||||
frame.set_visible(False)
|
||||
frame.set_name(_PLACEHOLDER)
|
||||
return
|
||||
|
@ -55,19 +55,19 @@ def _update_device_box(frame, devstatus):
|
|||
icon, label = _find_children(frame, 'icon', 'label')
|
||||
|
||||
frame.set_visible(True)
|
||||
if frame.get_name() != devstatus.name:
|
||||
frame.set_name(devstatus.name)
|
||||
if theme.has_icon(devstatus.name):
|
||||
icon.set_from_icon_name(devstatus.name, _DEVICE_ICON_SIZE)
|
||||
if frame.get_name() != dev.name:
|
||||
frame.set_name(dev.name)
|
||||
if theme.has_icon(dev.name):
|
||||
icon.set_from_icon_name(dev.name, _DEVICE_ICON_SIZE)
|
||||
else:
|
||||
icon.set_from_icon_name(devstatus.type.lower(), _DEVICE_ICON_SIZE)
|
||||
icon.set_tooltip_text(devstatus.name)
|
||||
label.set_markup('<b>' + devstatus.name + '</b>')
|
||||
icon.set_from_icon_name(dev.kind, _DEVICE_ICON_SIZE)
|
||||
icon.set_tooltip_text(dev.name)
|
||||
label.set_markup('<b>' + dev.name + '</b>')
|
||||
|
||||
status = _find_children(frame, 'status')
|
||||
if devstatus.code < C.STATUS.CONNECTED:
|
||||
if dev.status < STATUS.CONNECTED:
|
||||
icon.set_sensitive(False)
|
||||
icon.set_tooltip_text(devstatus.text)
|
||||
icon.set_tooltip_text(dev.status_text)
|
||||
label.set_sensitive(False)
|
||||
status.set_visible(False)
|
||||
return
|
||||
|
@ -79,7 +79,7 @@ def _update_device_box(frame, devstatus):
|
|||
status_icons = status.get_children()
|
||||
|
||||
battery_icon, battery_label = status_icons[0:2]
|
||||
battery_level = getattr(devstatus, C.PROPS.BATTERY_LEVEL, None)
|
||||
battery_level = dev.props.get(PROPS.BATTERY_LEVEL)
|
||||
if battery_level is None:
|
||||
battery_icon.set_sensitive(False)
|
||||
battery_icon.set_from_icon_name('battery_unknown', _STATUS_ICON_SIZE)
|
||||
|
@ -92,14 +92,14 @@ def _update_device_box(frame, devstatus):
|
|||
battery_label.set_sensitive(True)
|
||||
battery_label.set_text('%d%%' % battery_level)
|
||||
|
||||
battery_status = getattr(devstatus, C.PROPS.BATTERY_STATUS, None)
|
||||
battery_status = dev.props.get(PROPS.BATTERY_STATUS)
|
||||
if battery_status is None:
|
||||
battery_icon.set_tooltip_text('')
|
||||
else:
|
||||
battery_icon.set_tooltip_text(battery_status)
|
||||
|
||||
light_icon, light_label = status_icons[2:4]
|
||||
light_level = getattr(devstatus, C.PROPS.LIGHT_LEVEL, None)
|
||||
light_level = dev.props.get(PROPS.LIGHT_LEVEL)
|
||||
if light_level is None:
|
||||
light_icon.set_visible(False)
|
||||
light_label.set_visible(False)
|
||||
|
@ -111,24 +111,30 @@ def _update_device_box(frame, devstatus):
|
|||
light_label.set_text('%d lux' % light_level)
|
||||
|
||||
|
||||
def update(window, rstatus, devices, icon_name=None):
|
||||
def update(window, receiver, icon_name=None):
|
||||
if window and window.get_child():
|
||||
if icon_name is not None:
|
||||
window.set_icon_name(icon_name)
|
||||
|
||||
vbox = window.get_child()
|
||||
controls = list(vbox.get_children())
|
||||
_update_receiver_box(controls[0], rstatus)
|
||||
|
||||
_update_receiver_box(controls[0], receiver)
|
||||
|
||||
for index in range(1, len(controls)):
|
||||
_update_device_box(controls[index], devices.get(index))
|
||||
dev = receiver.devices[index] if index in receiver.devices else None
|
||||
_update_device_box(controls[index], dev)
|
||||
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
def _receiver_box(rstatus):
|
||||
def _receiver_box(name):
|
||||
box = _device_box(False, False)
|
||||
|
||||
icon, status_box = _find_children(box, 'icon', 'status')
|
||||
icon.set_from_icon_name(rstatus.name, _SMALL_DEVICE_ICON_SIZE)
|
||||
icon.set_tooltip_text(rstatus.name)
|
||||
icon.set_from_icon_name(name, _SMALL_DEVICE_ICON_SIZE)
|
||||
icon.set_tooltip_text(name)
|
||||
|
||||
toolbar = Gtk.Toolbar()
|
||||
toolbar.set_name('buttons')
|
||||
|
@ -139,10 +145,7 @@ def _receiver_box(rstatus):
|
|||
pair_button = Gtk.ToolButton()
|
||||
pair_button.set_icon_name('add')
|
||||
pair_button.set_tooltip_text('Pair new device')
|
||||
if rstatus.pair:
|
||||
pair_button.connect('clicked', rstatus.pair)
|
||||
else:
|
||||
pair_button.set_sensitive(False)
|
||||
pair_button.set_sensitive(False)
|
||||
toolbar.insert(pair_button, 0)
|
||||
|
||||
toolbar.show_all()
|
||||
|
@ -205,17 +208,18 @@ def _device_box(has_status_icons=True, has_frame=True):
|
|||
return box
|
||||
|
||||
|
||||
def create(title, rstatus, systray=False):
|
||||
def create(title, name, max_devices, systray=False):
|
||||
window = Gtk.Window()
|
||||
window.set_title(title)
|
||||
# window.set_icon_name(title)
|
||||
window.set_role('status-window')
|
||||
|
||||
vbox = Gtk.VBox(homogeneous=False, spacing=4)
|
||||
vbox.set_border_width(4)
|
||||
|
||||
rbox = _receiver_box(rstatus)
|
||||
rbox = _receiver_box(name)
|
||||
vbox.add(rbox)
|
||||
for i in range(1, 1 + rstatus.max_devices):
|
||||
for i in range(1, 1 + max_devices):
|
||||
dbox = _device_box()
|
||||
vbox.add(dbox)
|
||||
vbox.set_visible(True)
|
||||
|
@ -229,35 +233,40 @@ def create(title, rstatus, systray=False):
|
|||
window.set_resizable(False)
|
||||
|
||||
if systray:
|
||||
def _state_event(window, event):
|
||||
if event.new_window_state & Gdk.WindowState.ICONIFIED:
|
||||
# position = window.get_position()
|
||||
window.hide()
|
||||
window.deiconify()
|
||||
# window.move(*position)
|
||||
return True
|
||||
# def _state_event(w, e):
|
||||
# if e.new_window_state & Gdk.WindowState.ICONIFIED:
|
||||
# w.hide()
|
||||
# w.deiconify()
|
||||
# return True
|
||||
# window.connect('window-state-event', _state_event)
|
||||
|
||||
window.set_keep_above(True)
|
||||
# window.set_deletable(False)
|
||||
window.set_deletable(False)
|
||||
# window.set_decorated(False)
|
||||
window.set_position(Gtk.WindowPosition.MOUSE)
|
||||
# window.set_type_hint(Gdk.WindowTypeHint.MENU)
|
||||
# window.set_position(Gtk.WindowPosition.MOUSE)
|
||||
# ulgy, but hides the minimize icon from the window
|
||||
window.set_type_hint(Gdk.WindowTypeHint.MENU)
|
||||
window.set_skip_taskbar_hint(True)
|
||||
window.set_skip_pager_hint(True)
|
||||
|
||||
window.connect('window-state-event', _state_event)
|
||||
window.connect('delete-event', lambda w, e: toggle(None, window) or True)
|
||||
window.connect('delete-event', lambda w, e: toggle(None, w) or True)
|
||||
else:
|
||||
window.set_position(Gtk.WindowPosition.CENTER)
|
||||
# window.set_position(Gtk.WindowPosition.CENTER)
|
||||
window.connect('delete-event', Gtk.main_quit)
|
||||
|
||||
return window
|
||||
|
||||
|
||||
def toggle(_, window):
|
||||
def toggle(icon, window):
|
||||
if window.get_visible():
|
||||
# position = window.get_position()
|
||||
position = window.get_position()
|
||||
window.hide()
|
||||
# window.move(*position)
|
||||
window.move(*position)
|
||||
else:
|
||||
if icon:
|
||||
x, y = window.get_position()
|
||||
if x == 0 and y == 0:
|
||||
x, y, _ = Gtk.StatusIcon.position_menu(Gtk.Menu(), icon)
|
||||
window.move(x, y)
|
||||
window.present()
|
||||
return True
|
||||
|
|
266
app/watcher.py
266
app/watcher.py
|
@ -2,231 +2,105 @@
|
|||
#
|
||||
#
|
||||
|
||||
from threading import (Thread, Event)
|
||||
from threading import Thread
|
||||
import time
|
||||
from logging import getLogger as _Logger
|
||||
import logging
|
||||
from collections import namedtuple
|
||||
|
||||
from logitech.unifying_receiver import (api, base)
|
||||
from logitech.unifying_receiver.listener import EventsListener
|
||||
from logitech import devices
|
||||
from logitech.devices import constants as C
|
||||
from logitech.devices.constants import STATUS
|
||||
from receiver import Receiver
|
||||
|
||||
|
||||
_l = _Logger('watcher')
|
||||
|
||||
_UNIFYING_RECEIVER = 'Unifying Receiver'
|
||||
_NO_RECEIVER = 'Receiver not found.'
|
||||
_INITIALIZING = 'Initializing...'
|
||||
_SCANNING = 'Scanning...'
|
||||
_NO_DEVICES = 'No devices found.'
|
||||
_OKAY = 'Status ok.'
|
||||
_DUMMY_RECEIVER = namedtuple('_DUMMY_RECEIVER', ['NAME', 'kind', 'status', 'status_text', 'max_devices', 'devices'])
|
||||
_DUMMY_RECEIVER.__nonzero__ = lambda _: False
|
||||
_DUMMY_RECEIVER.device_name = Receiver.NAME
|
||||
DUMMY = _DUMMY_RECEIVER(Receiver.NAME, Receiver.NAME, STATUS.UNAVAILABLE, 'Receiver not found.', Receiver.max_devices, {})
|
||||
|
||||
|
||||
class _DevStatus(api.AttachedDeviceInfo):
|
||||
code = C.STATUS.UNKNOWN
|
||||
text = _INITIALIZING
|
||||
|
||||
def __str__(self):
|
||||
return 'DevStatus(%d,%s,%d)' % (self.number, self.name, self.code)
|
||||
def _sleep(seconds, granularity, breakout=lambda: False):
|
||||
for index in range(0, int(seconds / granularity)):
|
||||
if breakout():
|
||||
return
|
||||
time.sleep(granularity)
|
||||
|
||||
|
||||
class Watcher(Thread):
|
||||
"""Keeps a map of all attached devices and their statuses."""
|
||||
def __init__(self, apptitle, notify=None):
|
||||
"""Keeps an active receiver object if possible, and updates the UI when
|
||||
necessary.
|
||||
"""
|
||||
def __init__(self, apptitle, update_ui, notify=None):
|
||||
super(Watcher, self).__init__(group=apptitle, name='Watcher')
|
||||
self.daemon = True
|
||||
self._active = False
|
||||
|
||||
self.listener = None
|
||||
self.no_receiver = Event()
|
||||
self.update_ui = update_ui
|
||||
self.notify = notify or (lambda d: None)
|
||||
|
||||
self.rstatus = _DevStatus(0, 0xFF, 'UR', _UNIFYING_RECEIVER, ())
|
||||
self.rstatus.max_devices = api.C.MAX_ATTACHED_DEVICES
|
||||
self.rstatus.pair = None
|
||||
|
||||
self.devices = {}
|
||||
|
||||
self.notify = notify
|
||||
self.status_changed = Event()
|
||||
self.receiver = DUMMY
|
||||
|
||||
def run(self):
|
||||
self._active = True
|
||||
notify_missing = True
|
||||
|
||||
while self._active:
|
||||
if self.listener is None:
|
||||
receiver = api.open()
|
||||
if receiver:
|
||||
self._device_status_changed(self.rstatus, C.STATUS.BOOTING, _INITIALIZING)
|
||||
if self.receiver == DUMMY:
|
||||
r = Receiver.open()
|
||||
if r:
|
||||
logging.info("receiver %s ", r)
|
||||
self.update_ui(r)
|
||||
self.notify(r)
|
||||
r.events_handler = self._events_callback
|
||||
|
||||
init = (base.request(receiver, 0xFF, b'\x81\x00') and
|
||||
base.request(receiver, 0xFF, b'\x80\x00', b'\x00\x01') and
|
||||
base.request(receiver, 0xFF, b'\x81\x02'))
|
||||
if init:
|
||||
_l.debug("receiver initialized ok")
|
||||
# give it some time to read all devices
|
||||
r.status_changed.clear()
|
||||
_sleep(8, 0.4, r.status_changed.is_set)
|
||||
if r.devices:
|
||||
logging.info("%d device(s) found", len(r.devices))
|
||||
for d in r.devices.values():
|
||||
self.notify(d)
|
||||
else:
|
||||
_l.debug("receiver initialization failed")
|
||||
# if no devices found so far, assume none at all
|
||||
logging.info("no devices found")
|
||||
r.status = STATUS.CONNECTED
|
||||
|
||||
self._device_status_changed(self.rstatus, C.STATUS.BOOTING, _SCANNING)
|
||||
|
||||
self.listener = EventsListener(receiver, self._events_callback)
|
||||
self.listener.start()
|
||||
|
||||
_l.debug("requesting devices status")
|
||||
self.listener.request(base.request, 0xFF, b'\x80\x02', b'\x02')
|
||||
|
||||
# give it some time to get the devices
|
||||
time.sleep(3)
|
||||
elif not self.listener:
|
||||
self.listener = None
|
||||
self.devices.clear()
|
||||
|
||||
if self.listener:
|
||||
if self.devices:
|
||||
self._device_status_changed(self.rstatus, C.STATUS.CONNECTED, _OKAY)
|
||||
self.receiver = r
|
||||
notify_missing = True
|
||||
else:
|
||||
self._device_status_changed(self.rstatus, C.STATUS.CONNECTED, _NO_DEVICES)
|
||||
if notify_missing:
|
||||
_sleep(0.8, 0.4, lambda: not self._active)
|
||||
notify_missing = False
|
||||
self.update_ui(DUMMY)
|
||||
self.notify(DUMMY)
|
||||
_sleep(4, 0.4, lambda: not self._active)
|
||||
continue
|
||||
|
||||
self.no_receiver.wait()
|
||||
self.no_receiver.clear()
|
||||
else:
|
||||
self._device_status_changed(self.rstatus, C.STATUS.UNAVAILABLE, _NO_RECEIVER)
|
||||
time.sleep(3)
|
||||
if self._active:
|
||||
if self.receiver:
|
||||
logging.debug("waiting for status_changed")
|
||||
sc = self.receiver.status_changed
|
||||
sc.wait()
|
||||
sc.clear()
|
||||
logging.debug("status_changed %s %d", sc.reason, sc.urgent)
|
||||
self.update_ui(self.receiver)
|
||||
if sc.reason and sc.urgent:
|
||||
self.notify(sc.reason)
|
||||
else:
|
||||
self.receiver = DUMMY
|
||||
self.update_ui(DUMMY)
|
||||
self.notify(DUMMY)
|
||||
|
||||
if self.listener:
|
||||
self.listener.stop()
|
||||
self.listener = None
|
||||
if self.receiver:
|
||||
self.receiver.close()
|
||||
|
||||
def stop(self):
|
||||
if self._active:
|
||||
_l.debug("stopping %s", self)
|
||||
logging.info("stopping %s", self)
|
||||
self._active = False
|
||||
self.no_receiver.set()
|
||||
if self.receiver:
|
||||
# break out of an eventual wait()
|
||||
self.receiver.status_changed.reason = None
|
||||
self.receiver.status_changed.set()
|
||||
self.join()
|
||||
|
||||
def request_status(self, devstatus=None, **kwargs):
|
||||
"""Trigger a status update on a device."""
|
||||
if self.listener:
|
||||
if devstatus is None or devstatus == self.rstatus:
|
||||
for devstatus in self.devices.values():
|
||||
self.request_status(devstatus)
|
||||
else:
|
||||
status = devices.request_status(devstatus, self.listener)
|
||||
self._handle_status(devstatus, status)
|
||||
|
||||
def _handle_status(self, devstatus, status):
|
||||
if status is not None:
|
||||
if type(status) == int:
|
||||
self._device_status_changed(devstatus, status)
|
||||
else:
|
||||
self._device_status_changed(devstatus, *status)
|
||||
|
||||
def _new_device(self, dev):
|
||||
if not self._active:
|
||||
return None
|
||||
|
||||
if type(dev) == int:
|
||||
assert self.listener
|
||||
dev = self.listener.request(api.get_device_info, dev)
|
||||
|
||||
if dev:
|
||||
devstatus = _DevStatus(*dev)
|
||||
self.devices[dev.number] = devstatus
|
||||
self._device_status_changed(devstatus, C.STATUS.CONNECTED)
|
||||
_l.debug("new devstatus %s", devstatus)
|
||||
self._device_status_changed(self.rstatus, C.STATUS.CONNECTED, _OKAY)
|
||||
return devstatus
|
||||
|
||||
def _device_status_changed(self, devstatus, status_code, status_data=None):
|
||||
old_status_code = devstatus.code
|
||||
status_text = devstatus.text
|
||||
|
||||
if status_data is None:
|
||||
if status_code in C.STATUS_NAME:
|
||||
status_text = C.STATUS_NAME[status_code]
|
||||
elif isinstance(status_data, str):
|
||||
status_text = status_data
|
||||
elif isinstance(status_data, dict):
|
||||
status_text = ''
|
||||
for key, value in status_data.items():
|
||||
if key == 'text':
|
||||
status_text = value
|
||||
else:
|
||||
setattr(devstatus, key, value)
|
||||
else:
|
||||
_l.warn("don't know how to handle status %s", status_data)
|
||||
return False
|
||||
|
||||
if status_code >= C.STATUS.CONNECTED and devstatus.type is None:
|
||||
# ghost device that became active
|
||||
if devstatus.code != C.STATUS.CONNECTED:
|
||||
# initial update, while we're getting the devinfo
|
||||
devstatus.code = C.STATUS.CONNECTED
|
||||
devstatus.text = C.STATUS_NAME[C.STATUS.CONNECTED]
|
||||
self.status_changed.set()
|
||||
if self._new_device(devstatus.number) is None:
|
||||
_l.warn("could not materialize device from %s", devstatus)
|
||||
return False
|
||||
|
||||
if ((status_code == old_status_code and status_text == devstatus.text) or
|
||||
(status_code == C.STATUS.CONNECTED and old_status_code > C.STATUS.CONNECTED)):
|
||||
# this is just successful ping for a device with an already known status
|
||||
return False
|
||||
|
||||
devstatus.code = status_code
|
||||
devstatus.text = status_text
|
||||
_l.debug("%s update %s => %s: %s", devstatus, old_status_code, status_code, status_text)
|
||||
|
||||
if self.notify and (status_code <= C.STATUS.CONNECTED or status_code != old_status_code):
|
||||
self.notify(devstatus.code, devstatus.name, devstatus.text)
|
||||
|
||||
self.status_changed.set()
|
||||
return True
|
||||
|
||||
def _events_callback(self, event):
|
||||
if event.code == 0xFF and event.devnumber == 0xFF and event.data is None:
|
||||
self.no_receiver.set()
|
||||
return
|
||||
|
||||
if event.code == 0x10 and event.data[0:2] == b'\x41\x04':
|
||||
# 2 = 0010 ping
|
||||
# 6 = 0110 off
|
||||
# a = 1010 on
|
||||
change = ord(event.data[2:3]) & 0xF0
|
||||
status_code = C.STATUS.UNAVAILABLE if change == 0x60 else \
|
||||
C.STATUS.CONNECTED if change == 0xA0 else \
|
||||
C.STATUS.CONNECTED if change == 0x20 else \
|
||||
None
|
||||
if status_code is None:
|
||||
_l.warn("don't know how to handle status %x: %s", change, event)
|
||||
return
|
||||
|
||||
if event.devnumber in self.devices:
|
||||
devstatus = self.devices[event.devnumber]
|
||||
self._device_status_changed(devstatus, status_code)
|
||||
return
|
||||
|
||||
if status_code == C.STATUS.CONNECTED:
|
||||
self._new_device(event.devnumber)
|
||||
return
|
||||
|
||||
# a device the UR knows about, but is not connected at this time
|
||||
dev_id = self.listener.request(base.request, 0xFF, b'\x83\xB5', event.data[4:5])
|
||||
name = str(dev_id[2:].rstrip(b'\x00')) if dev_id else '?'
|
||||
name = devices.C.FULL_NAME[name]
|
||||
ghost = _DevStatus(handle=self.listener.receiver, number=event.devnumber, type=None, name=name, features=[])
|
||||
self.devices[event.devnumber] = ghost
|
||||
self._device_status_changed(ghost, C.STATUS.UNAVAILABLE)
|
||||
self._device_status_changed(self.rstatus, C.STATUS.CONNECTED, _OKAY)
|
||||
return
|
||||
|
||||
if event.devnumber in self.devices:
|
||||
devstatus = self.devices[event.devnumber]
|
||||
if event.code == 0x11:
|
||||
status = devices.process_event(devstatus, event.data, self.listener)
|
||||
self._handle_status(devstatus, status)
|
||||
return
|
||||
if event.code == 0x10 and event.data[:1] == b'\x8F':
|
||||
self._device_status_changed(devstatus, C.STATUS.UNAVAILABLE)
|
||||
return
|
||||
|
||||
_l.warn("don't know how to handle event %s", event)
|
||||
logging.warn("don't know how to handle event %s", event)
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
#!/bin/sh
|
||||
|
||||
Z=`readlink -f "$0"`
|
||||
APP=`dirname "$Z"`/../app
|
||||
LIB=`dirname "$Z"`/../lib
|
||||
SHARE=`dirname "$Z"`/../share
|
||||
APP=`readlink -f $(dirname "$Z")/../app`
|
||||
LIB=`readlink -f $(dirname "$Z")/../lib`
|
||||
SHARE=`readlink -f $(dirname "$Z")/../share`
|
||||
|
||||
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:$LIB/native/`uname -m`
|
||||
export PYTHONPATH=$APP:$LIB
|
||||
|
|
|
@ -4,50 +4,49 @@
|
|||
|
||||
import logging
|
||||
|
||||
from . import k750
|
||||
from . import constants as C
|
||||
|
||||
from .constants import (STATUS, PROPS)
|
||||
from ..unifying_receiver.constants import (FEATURE, BATTERY_STATUS)
|
||||
from ..unifying_receiver import api as _api
|
||||
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
_REQUEST_STATUS_FUNCTIONS = {
|
||||
C.NAME.K750: k750.request_status,
|
||||
}
|
||||
_DEVICE_MODULES = {}
|
||||
|
||||
_PROCESS_EVENT_FUNCTIONS = {
|
||||
C.NAME.K750: k750.process_event,
|
||||
}
|
||||
def _module(device_name):
|
||||
if device_name not in _DEVICE_MODULES:
|
||||
shortname = device_name.split(' ')[-1].lower()
|
||||
try:
|
||||
m = __import__(shortname, globals(), level=1)
|
||||
_DEVICE_MODULES[device_name] = m
|
||||
except:
|
||||
# logging.exception(shortname)
|
||||
_DEVICE_MODULES[device_name] = None
|
||||
|
||||
return _DEVICE_MODULES[device_name]
|
||||
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
def ping(devinfo, listener=None):
|
||||
if listener is None:
|
||||
reply = _api.ping(devinfo.number)
|
||||
elif listener:
|
||||
reply = listener.request(_api.ping, devinfo.number)
|
||||
else:
|
||||
return None
|
||||
|
||||
return C.STATUS.CONNECTED if reply else C.STATUS.UNAVAILABLE
|
||||
|
||||
|
||||
def default_request_status(devinfo, listener=None):
|
||||
if _api.C.FEATURE.BATTERY in devinfo.features:
|
||||
if listener is None:
|
||||
reply = _api.get_device_battery_level(devinfo.handle, devinfo.number, features=devinfo.features)
|
||||
elif listener:
|
||||
if FEATURE.BATTERY in devinfo.features:
|
||||
if listener:
|
||||
reply = listener.request(_api.get_device_battery_level, devinfo.number, features=devinfo.features)
|
||||
else:
|
||||
reply = None
|
||||
reply = _api.get_device_battery_level(devinfo.handle, devinfo.number, features=devinfo.features)
|
||||
|
||||
if reply:
|
||||
discharge, dischargeNext, status = reply
|
||||
return C.STATUS.CONNECTED, {C.PROPS.BATTERY_LEVEL: discharge}
|
||||
return STATUS.CONNECTED, {PROPS.BATTERY_LEVEL: discharge, PROPS.BATTERY_STATUS: status}
|
||||
|
||||
if listener:
|
||||
reply = listener.request(_api.ping, devinfo.number)
|
||||
else:
|
||||
reply = _api.ping(devinfo.handle, devinfo.number)
|
||||
|
||||
return STATUS.CONNECTED if reply else STATUS.UNAVAILABLE
|
||||
|
||||
|
||||
def default_process_event(devinfo, data, listener=None):
|
||||
|
@ -59,21 +58,23 @@ def default_process_event(devinfo, data, listener=None):
|
|||
feature = devinfo.features[feature_index]
|
||||
feature_function = ord(data[1:2]) & 0xF0
|
||||
|
||||
if feature == _api.C.FEATURE.BATTERY:
|
||||
if feature == FEATURE.BATTERY:
|
||||
if feature_function == 0:
|
||||
discharge = ord(data[2:3])
|
||||
status = _api.C.BATTERY_STATUS[ord(data[3:4])]
|
||||
return C.STATUS.CONNECTED, {C.PROPS.BATTERY_LEVEL: discharge, C.PROPS.BATTERY_STATUS: status}
|
||||
status = BATTERY_STATUS[ord(data[3:4])]
|
||||
return STATUS.CONNECTED, {PROPS.BATTERY_LEVEL: discharge, PROPS.BATTERY_STATUS: status}
|
||||
# ?
|
||||
elif feature == _api.C.FEATURE.REPROGRAMMABLE_KEYS:
|
||||
elif feature == FEATURE.REPROGRAMMABLE_KEYS:
|
||||
if feature_function == 0:
|
||||
logging.debug('reprogrammable key: %s', repr(data))
|
||||
# TODO
|
||||
pass
|
||||
# ?
|
||||
elif feature == _api.C.FEATURE.WIRELESS:
|
||||
elif feature == FEATURE.WIRELESS:
|
||||
if feature_function == 0:
|
||||
logging.debug("wireless status: %s", repr(data))
|
||||
if data[2:5] == b'\x01\x01\x01':
|
||||
return STATUS.CONNECTED
|
||||
# TODO
|
||||
pass
|
||||
# ?
|
||||
|
@ -86,9 +87,10 @@ def request_status(devinfo, listener=None):
|
|||
:param listener: the EventsListener that will be used to send the request,
|
||||
and which will receive the status events from the device.
|
||||
"""
|
||||
if devinfo.name in _REQUEST_STATUS_FUNCTIONS:
|
||||
return _REQUEST_STATUS_FUNCTIONS[devinfo.name](devinfo, listener)
|
||||
return default_request_status(devinfo, listener) or ping(devinfo, listener)
|
||||
m = _module(devinfo.name)
|
||||
if m and 'request_status' in m.__dict__:
|
||||
return m.request_status(devinfo, listener)
|
||||
return default_request_status(devinfo, listener)
|
||||
|
||||
|
||||
def process_event(devinfo, data, listener=None):
|
||||
|
@ -101,5 +103,6 @@ def process_event(devinfo, data, listener=None):
|
|||
if default_result is not None:
|
||||
return default_result
|
||||
|
||||
if devinfo.name in _PROCESS_EVENT_FUNCTIONS:
|
||||
return _PROCESS_EVENT_FUNCTIONS[devinfo.name](devinfo, data, listener)
|
||||
m = _module(devinfo.name)
|
||||
if m and 'process_event' in m.__dict__:
|
||||
return m.process_event(devinfo, data, listener)
|
||||
|
|
|
@ -11,47 +11,32 @@ STATUS = type('STATUS', (),
|
|||
))
|
||||
|
||||
STATUS_NAME = {
|
||||
STATUS.UNKNOWN: '...',
|
||||
STATUS.UNAVAILABLE: 'inactive',
|
||||
STATUS.BOOTING: 'initializing',
|
||||
STATUS.CONNECTED: 'connected',
|
||||
}
|
||||
|
||||
|
||||
# device properties that may be reported
|
||||
PROPS = type('PROPS', (),
|
||||
dict(
|
||||
TEXT='text',
|
||||
BATTERY_LEVEL='battery_level',
|
||||
BATTERY_STATUS='battery_status',
|
||||
LIGHT_LEVEL='light_level',
|
||||
))
|
||||
|
||||
|
||||
NAME = type('NAME', (),
|
||||
dict(
|
||||
M315='Wireless Mouse M315',
|
||||
M325='Wireless Mouse M325',
|
||||
M510='Wireless Mouse M510',
|
||||
M515='Couch Mouse M515',
|
||||
M570='Wireless Trackball M570',
|
||||
K270='Wireless Keyboard K270',
|
||||
K350='Wireless Keyboard K350',
|
||||
K750='Wireless Solar Keyboard K750',
|
||||
K800='Wireless Illuminated Keyboard K800',
|
||||
))
|
||||
|
||||
from ..unifying_receiver.common import FallbackDict
|
||||
|
||||
FULL_NAME = FallbackDict(lambda x: x,
|
||||
dict(
|
||||
M315=NAME.M315,
|
||||
M325=NAME.M325,
|
||||
M510=NAME.M510,
|
||||
M515=NAME.M515,
|
||||
M570=NAME.M570,
|
||||
K270=NAME.K270,
|
||||
K350=NAME.K350,
|
||||
K750=NAME.K750,
|
||||
K800=NAME.K800,
|
||||
))
|
||||
|
||||
del FallbackDict
|
||||
# when the receiver reports a device that is not connected
|
||||
# (and thus cannot be queried), guess the name and type
|
||||
# based on this table
|
||||
NAMES = {
|
||||
'M315': ('Wireless Mouse M315', 'mouse'),
|
||||
'M325': ('Wireless Mouse M325', 'mouse'),
|
||||
'M510': ('Wireless Mouse M510', 'mouse'),
|
||||
'M515': ('Couch Mouse M515', 'mouse'),
|
||||
'M570': ('Wireless Trackball M570', 'trackball'),
|
||||
'K270': ('Wireless Keyboard K270', 'keyboard'),
|
||||
'K350': ('Wireless Keyboard K350', 'keyboard'),
|
||||
'K750': ('Wireless Solar Keyboard K750', 'keyboard'),
|
||||
'K800': ('Wireless Illuminated Keyboard K800', 'keyboard'),
|
||||
}
|
||||
|
|
|
@ -5,40 +5,33 @@
|
|||
import logging
|
||||
from struct import unpack as _unpack
|
||||
|
||||
from .constants import (STATUS, PROPS)
|
||||
from ..unifying_receiver.constants import FEATURE
|
||||
from ..unifying_receiver import api as _api
|
||||
from . import constants as C
|
||||
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
_CHARGE_LEVELS = (10, 25, 256)
|
||||
def _charge_status(data, hasLux=False):
|
||||
charge, lux = _unpack('!BH', data[2:5])
|
||||
|
||||
d = {}
|
||||
|
||||
_CHARGE_LEVELS = (10, 25, 256)
|
||||
for i in range(0, len(_CHARGE_LEVELS)):
|
||||
if charge < _CHARGE_LEVELS[i]:
|
||||
charge_index = i
|
||||
break
|
||||
d[C.PROPS.BATTERY_LEVEL] = charge
|
||||
text = 'Battery %d%%' % charge
|
||||
|
||||
if hasLux:
|
||||
d[C.PROPS.LIGHT_LEVEL] = lux
|
||||
text = 'Light: %d lux' % lux + ', ' + text
|
||||
else:
|
||||
d[C.PROPS.LIGHT_LEVEL] = None
|
||||
|
||||
d[C.PROPS.TEXT] = text
|
||||
return 0x10 << charge_index, d
|
||||
return 0x10 << charge_index, {
|
||||
PROPS.BATTERY_LEVEL: charge,
|
||||
PROPS.LIGHT_LEVEL: lux if hasLux else None,
|
||||
}
|
||||
|
||||
|
||||
def request_status(devinfo, listener=None):
|
||||
def _trigger_solar_charge_events(handle, devinfo):
|
||||
return _api.request(handle, devinfo.number,
|
||||
feature=_api.C.FEATURE.SOLAR_CHARGE, function=b'\x03', params=b'\x78\x01',
|
||||
feature=FEATURE.SOLAR_CHARGE, function=b'\x03', params=b'\x78\x01',
|
||||
features=devinfo.features)
|
||||
if listener is None:
|
||||
reply = _trigger_solar_charge_events(devinfo.handle, devinfo)
|
||||
|
@ -48,7 +41,7 @@ def request_status(devinfo, listener=None):
|
|||
reply = 0
|
||||
|
||||
if reply is None:
|
||||
return C.STATUS.UNAVAILABLE
|
||||
return STATUS.UNAVAILABLE
|
||||
|
||||
|
||||
def process_event(devinfo, data, listener=None):
|
||||
|
@ -68,4 +61,4 @@ def process_event(devinfo, data, listener=None):
|
|||
# wireless device status
|
||||
if data[2:5] == b'\x01\x01\x01':
|
||||
logging.debug("Keyboard just started")
|
||||
return C.STATUS.CONNECTED
|
||||
return STATUS.CONNECTED
|
||||
|
|
|
@ -7,12 +7,15 @@ from struct import pack as _pack
|
|||
from struct import unpack as _unpack
|
||||
from binascii import hexlify as _hexlify
|
||||
|
||||
from .common import FirmwareInfo
|
||||
from .common import AttachedDeviceInfo
|
||||
from .common import ReprogrammableKeyInfo
|
||||
from . import constants as C
|
||||
from . import exceptions as E
|
||||
from . import base as _base
|
||||
from .common import (FirmwareInfo as _FirmwareInfo,
|
||||
AttachedDeviceInfo as _AttachedDeviceInfo,
|
||||
ReprogrammableKeyInfo as _ReprogrammableKeyInfo)
|
||||
from .constants import (FEATURE, FEATURE_NAME, FEATURE_FLAGS,
|
||||
FIRMWARE_KIND, DEVICE_KIND,
|
||||
BATTERY_STATUS, KEY_NAME,
|
||||
MAX_ATTACHED_DEVICES)
|
||||
from .exceptions import FeatureNotSupported as _FeatureNotSupported
|
||||
|
||||
|
||||
_LOG_LEVEL = 5
|
||||
|
@ -83,7 +86,7 @@ def request(handle, devnumber, feature, function=b'\x00', params=b'', features=N
|
|||
"""
|
||||
|
||||
feature_index = None
|
||||
if feature == C.FEATURE.ROOT:
|
||||
if feature == FEATURE.ROOT:
|
||||
feature_index = b'\x00'
|
||||
else:
|
||||
if features is None:
|
||||
|
@ -95,8 +98,8 @@ def request(handle, devnumber, feature, function=b'\x00', params=b'', features=N
|
|||
feature_index = _pack('!B', features.index(feature))
|
||||
|
||||
if feature_index is None:
|
||||
_l.warn("(%d) feature <%s:%s> not supported", devnumber, _hexlify(feature), C.FEATURE_NAME[feature])
|
||||
raise E.FeatureNotSupported(devnumber, feature)
|
||||
_l.warn("(%d) feature <%s:%s> not supported", devnumber, _hexlify(feature), FEATURE_NAME[feature])
|
||||
raise _FeatureNotSupported(devnumber, feature)
|
||||
|
||||
if type(function) == int:
|
||||
function = _pack('!B', function)
|
||||
|
@ -129,7 +132,7 @@ def find_device_by_name(handle, name):
|
|||
"""
|
||||
_l.log(_LOG_LEVEL, "searching for device '%s'", name)
|
||||
|
||||
for devnumber in range(1, 1 + C.MAX_ATTACHED_DEVICES):
|
||||
for devnumber in range(1, 1 + MAX_ATTACHED_DEVICES):
|
||||
features = get_device_features(handle, devnumber)
|
||||
if features:
|
||||
d_name = get_device_name(handle, devnumber, features)
|
||||
|
@ -146,7 +149,7 @@ def list_devices(handle):
|
|||
|
||||
devices = []
|
||||
|
||||
for device in range(1, 1 + C.MAX_ATTACHED_DEVICES):
|
||||
for device in range(1, 1 + MAX_ATTACHED_DEVICES):
|
||||
features = get_device_features(handle, device)
|
||||
if features:
|
||||
devices.append(get_device_info(handle, device, features=features))
|
||||
|
@ -164,9 +167,9 @@ def get_device_info(handle, devnumber, name=None, features=None):
|
|||
if features is None:
|
||||
return None
|
||||
|
||||
d_type = get_device_type(handle, devnumber, features)
|
||||
d_kind = get_device_kind(handle, devnumber, features)
|
||||
d_name = get_device_name(handle, devnumber, features) if name is None else name
|
||||
devinfo = AttachedDeviceInfo(handle, devnumber, d_type, d_name, features)
|
||||
devinfo = _AttachedDeviceInfo(handle, devnumber, d_kind, d_name, features)
|
||||
_l.log(_LOG_LEVEL, "(%d) found device %s", devnumber, devinfo)
|
||||
return devinfo
|
||||
|
||||
|
@ -176,12 +179,12 @@ def get_feature_index(handle, devnumber, feature):
|
|||
|
||||
:returns: An int, or ``None`` if the feature is not available.
|
||||
"""
|
||||
_l.log(_LOG_LEVEL, "(%d) get feature index <%s:%s>", devnumber, _hexlify(feature), C.FEATURE_NAME[feature])
|
||||
_l.log(_LOG_LEVEL, "(%d) get feature index <%s:%s>", devnumber, _hexlify(feature), FEATURE_NAME[feature])
|
||||
if len(feature) != 2:
|
||||
raise ValueError("invalid feature <%s>: it must be a two-byte string" % feature)
|
||||
|
||||
# FEATURE.ROOT should always be available for any attached devices
|
||||
reply = _base.request(handle, devnumber, C.FEATURE.ROOT, feature)
|
||||
reply = _base.request(handle, devnumber, FEATURE.ROOT, feature)
|
||||
if reply:
|
||||
# only consider active and supported features
|
||||
feature_index = ord(reply[0:1])
|
||||
|
@ -190,18 +193,18 @@ def get_feature_index(handle, devnumber, feature):
|
|||
if _l.isEnabledFor(_LOG_LEVEL):
|
||||
if feature_flags:
|
||||
_l.log(_LOG_LEVEL, "(%d) feature <%s:%s> has index %d: %s",
|
||||
devnumber, _hexlify(feature), C.FEATURE_NAME[feature], feature_index,
|
||||
','.join([C.FEATURE_FLAGS[k] for k in C.FEATURE_FLAGS if feature_flags & k]))
|
||||
devnumber, _hexlify(feature), FEATURE_NAME[feature], feature_index,
|
||||
','.join([FEATURE_FLAGS[k] for k in FEATURE_FLAGS if feature_flags & k]))
|
||||
else:
|
||||
_l.log(_LOG_LEVEL, "(%d) feature <%s:%s> has index %d", devnumber, _hexlify(feature), C.FEATURE_NAME[feature], feature_index)
|
||||
_l.log(_LOG_LEVEL, "(%d) feature <%s:%s> has index %d", devnumber, _hexlify(feature), FEATURE_NAME[feature], feature_index)
|
||||
|
||||
# if feature_flags:
|
||||
# raise E.FeatureNotSupported(devnumber, feature)
|
||||
|
||||
return feature_index
|
||||
|
||||
_l.warn("(%d) feature <%s:%s> not supported by the device", devnumber, _hexlify(feature), C.FEATURE_NAME[feature])
|
||||
raise E.FeatureNotSupported(devnumber, feature)
|
||||
_l.warn("(%d) feature <%s:%s> not supported by the device", devnumber, _hexlify(feature), FEATURE_NAME[feature])
|
||||
raise _FeatureNotSupported(devnumber, feature)
|
||||
|
||||
|
||||
def get_device_features(handle, devnumber):
|
||||
|
@ -214,7 +217,7 @@ def get_device_features(handle, devnumber):
|
|||
|
||||
# get the index of the FEATURE_SET
|
||||
# FEATURE.ROOT should always be available for all devices
|
||||
fs_index = _base.request(handle, devnumber, C.FEATURE.ROOT, C.FEATURE.FEATURE_SET)
|
||||
fs_index = _base.request(handle, devnumber, FEATURE.ROOT, FEATURE.FEATURE_SET)
|
||||
if fs_index is None:
|
||||
# _l.warn("(%d) FEATURE_SET not available", device)
|
||||
return None
|
||||
|
@ -246,15 +249,15 @@ def get_device_features(handle, devnumber):
|
|||
if _l.isEnabledFor(_LOG_LEVEL):
|
||||
if feature_flags:
|
||||
_l.log(_LOG_LEVEL, "(%d) feature <%s:%s> at index %d: %s",
|
||||
devnumber, _hexlify(feature), C.FEATURE_NAME[feature], index,
|
||||
','.join([C.FEATURE_FLAGS[k] for k in C.FEATURE_FLAGS if feature_flags & k]))
|
||||
devnumber, _hexlify(feature), FEATURE_NAME[feature], index,
|
||||
','.join([FEATURE_FLAGS[k] for k in FEATURE_FLAGS if feature_flags & k]))
|
||||
else:
|
||||
_l.log(_LOG_LEVEL, "(%d) feature <%s:%s> at index %d", devnumber, _hexlify(feature), C.FEATURE_NAME[feature], index)
|
||||
_l.log(_LOG_LEVEL, "(%d) feature <%s:%s> at index %d", devnumber, _hexlify(feature), FEATURE_NAME[feature], index)
|
||||
|
||||
features[0] = C.FEATURE.ROOT
|
||||
features[0] = FEATURE.ROOT
|
||||
while features[-1] is None:
|
||||
del features[-1]
|
||||
return features
|
||||
return tuple(features)
|
||||
|
||||
|
||||
def get_device_firmware(handle, devnumber, features=None):
|
||||
|
@ -262,20 +265,20 @@ def get_device_firmware(handle, devnumber, features=None):
|
|||
|
||||
:returns: a list of FirmwareInfo tuples, ordered by firmware layer.
|
||||
"""
|
||||
def _makeFirmwareInfo(level, type, name=None, version=None, build=None, extras=None):
|
||||
return FirmwareInfo(level, type, name, version, build, extras)
|
||||
def _makeFirmwareInfo(level, kind, name=None, version=None, build=None, extras=None):
|
||||
return _FirmwareInfo(level, kind, name, version, build, extras)
|
||||
|
||||
fw_count = request(handle, devnumber, C.FEATURE.FIRMWARE, features=features)
|
||||
fw_count = request(handle, devnumber, FEATURE.FIRMWARE, features=features)
|
||||
if fw_count:
|
||||
fw_count = ord(fw_count[:1])
|
||||
|
||||
fw = []
|
||||
for index in range(0, fw_count):
|
||||
fw_info = request(handle, devnumber, C.FEATURE.FIRMWARE, function=b'\x10', params=index, features=features)
|
||||
fw_info = request(handle, devnumber, FEATURE.FIRMWARE, function=b'\x10', params=index, features=features)
|
||||
if fw_info:
|
||||
fw_level = ord(fw_info[:1]) & 0x0F
|
||||
if fw_level == 0 or fw_level == 1:
|
||||
fw_type = C.FIRMWARE_TYPE[fw_level]
|
||||
level = ord(fw_info[:1]) & 0x0F
|
||||
if level == 0 or level == 1:
|
||||
kind = FIRMWARE_KIND[level]
|
||||
name, = _unpack('!3s', fw_info[1:4])
|
||||
name = name.decode('ascii')
|
||||
version = _hexlify(fw_info[4:6])
|
||||
|
@ -283,31 +286,31 @@ def get_device_firmware(handle, devnumber, features=None):
|
|||
build, = _unpack('!H', fw_info[6:8])
|
||||
extras = fw_info[9:].rstrip(b'\x00')
|
||||
if extras:
|
||||
fw_info = _makeFirmwareInfo(level=fw_level, type=fw_type, name=name, version=version, build=build, extras=extras)
|
||||
fw_info = _makeFirmwareInfo(level, kind, name, version, build, extras)
|
||||
else:
|
||||
fw_info = _makeFirmwareInfo(level=fw_level, type=fw_type, name=name, version=version, build=build)
|
||||
elif fw_level == 2:
|
||||
fw_info = _makeFirmwareInfo(level=2, type=C.FIRMWARE_TYPE[2], version=ord(fw_info[1:2]))
|
||||
fw_info = _makeFirmwareInfo(level, kind, name, version, build)
|
||||
elif level == 2:
|
||||
fw_info = _makeFirmwareInfo(2, FIRMWARE_KIND[2], version=ord(fw_info[1:2]))
|
||||
else:
|
||||
fw_info = _makeFirmwareInfo(level=fw_level, type=C.FIRMWARE_TYPE[-1])
|
||||
fw_info = _makeFirmwareInfo(level, FIRMWARE_KIND[-1])
|
||||
|
||||
fw.append(fw_info)
|
||||
_l.log(_LOG_LEVEL, "(%d) firmware %s", devnumber, fw_info)
|
||||
return fw
|
||||
return tuple(fw)
|
||||
|
||||
|
||||
def get_device_type(handle, devnumber, features=None):
|
||||
def get_device_kind(handle, devnumber, features=None):
|
||||
"""Reads a device's type.
|
||||
|
||||
:see DEVICE_TYPE:
|
||||
:see DEVICE_KIND:
|
||||
:returns: a string describing the device type, or ``None`` if the device is
|
||||
not available or does not support the ``NAME`` feature.
|
||||
"""
|
||||
d_type = request(handle, devnumber, C.FEATURE.NAME, function=b'\x20', features=features)
|
||||
if d_type:
|
||||
d_type = ord(d_type[:1])
|
||||
_l.log(_LOG_LEVEL, "(%d) device type %d = %s", devnumber, d_type, C.DEVICE_TYPE[d_type])
|
||||
return C.DEVICE_TYPE[d_type]
|
||||
d_kind = request(handle, devnumber, FEATURE.NAME, function=b'\x20', features=features)
|
||||
if d_kind:
|
||||
d_kind = ord(d_kind[:1])
|
||||
_l.log(_LOG_LEVEL, "(%d) device type %d = %s", devnumber, d_kind, DEVICE_KIND[d_kind])
|
||||
return DEVICE_KIND[d_kind]
|
||||
|
||||
|
||||
def get_device_name(handle, devnumber, features=None):
|
||||
|
@ -316,13 +319,13 @@ def get_device_name(handle, devnumber, features=None):
|
|||
:returns: a string with the device name, or ``None`` if the device is not
|
||||
available or does not support the ``NAME`` feature.
|
||||
"""
|
||||
name_length = request(handle, devnumber, C.FEATURE.NAME, features=features)
|
||||
name_length = request(handle, devnumber, FEATURE.NAME, features=features)
|
||||
if name_length:
|
||||
name_length = ord(name_length[:1])
|
||||
|
||||
d_name = b''
|
||||
while len(d_name) < name_length:
|
||||
name_fragment = request(handle, devnumber, C.FEATURE.NAME, function=b'\x10', params=len(d_name), features=features)
|
||||
name_fragment = request(handle, devnumber, FEATURE.NAME, function=b'\x10', params=len(d_name), features=features)
|
||||
if name_fragment:
|
||||
name_fragment = name_fragment[:name_length - len(d_name)]
|
||||
d_name += name_fragment
|
||||
|
@ -339,24 +342,25 @@ def get_device_battery_level(handle, devnumber, features=None):
|
|||
|
||||
:raises FeatureNotSupported: if the device does not support this feature.
|
||||
"""
|
||||
battery = request(handle, devnumber, C.FEATURE.BATTERY, features=features)
|
||||
battery = request(handle, devnumber, FEATURE.BATTERY, features=features)
|
||||
if battery:
|
||||
discharge, dischargeNext, status = _unpack('!BBB', battery[:3])
|
||||
_l.log(_LOG_LEVEL, "(%d) battery %d%% charged, next level %d%% charge, status %d = %s",
|
||||
devnumber, discharge, dischargeNext, status, C.BATTERY_STATUSE[status])
|
||||
return (discharge, dischargeNext, C.BATTERY_STATUS[status])
|
||||
devnumber, discharge, dischargeNext, status, BATTERY_STATUS[status])
|
||||
return (discharge, dischargeNext, BATTERY_STATUS[status])
|
||||
|
||||
|
||||
def get_device_keys(handle, devnumber, features=None):
|
||||
count = request(handle, devnumber, C.FEATURE.REPROGRAMMABLE_KEYS, features=features)
|
||||
count = request(handle, devnumber, FEATURE.REPROGRAMMABLE_KEYS, features=features)
|
||||
if count:
|
||||
keys = []
|
||||
|
||||
count = ord(count[:1])
|
||||
for index in range(0, count):
|
||||
keydata = request(handle, devnumber, C.FEATURE.REPROGRAMMABLE_KEYS, function=b'\x10', params=index, features=features)
|
||||
keydata = request(handle, devnumber, FEATURE.REPROGRAMMABLE_KEYS, function=b'\x10', params=index, features=features)
|
||||
if keydata:
|
||||
key, key_task, flags = _unpack('!HHB', keydata[:5])
|
||||
keys.append(ReprogrammableKeyInfo(index, key, C.KEY_NAME[key], key_task, C.KEY_NAME[key_task], flags))
|
||||
rki = _ReprogrammableKeyInfo(index, key, KEY_NAME[key], key_task, KEY_NAME[key_task], flags)
|
||||
keys.append(rki)
|
||||
|
||||
return keys
|
||||
|
|
|
@ -7,8 +7,9 @@ from logging import getLogger as _Logger
|
|||
from struct import pack as _pack
|
||||
from binascii import hexlify as _hexlify
|
||||
|
||||
from . import constants as C
|
||||
from . import exceptions as E
|
||||
from .constants import ERROR_NAME
|
||||
from .exceptions import (NoReceiver as _NoReceiver,
|
||||
FeatureCallError as _FeatureCallError)
|
||||
|
||||
import hidapi as _hid
|
||||
|
||||
|
@ -96,7 +97,7 @@ def try_open(path):
|
|||
_l.log(_LOG_LEVEL, "[%s] open failed", path)
|
||||
return None
|
||||
|
||||
_l.log(_LOG_LEVEL, "[%s] receiver handle 0x%x", path, receiver_handle)
|
||||
_l.log(_LOG_LEVEL, "[%s] receiver handle %x", path, receiver_handle)
|
||||
# ping on device id 0 (always an error)
|
||||
_hid.write(receiver_handle, b'\x10\x00\x00\x10\x00\x00\xAA')
|
||||
|
||||
|
@ -176,7 +177,7 @@ def write(handle, devnumber, data):
|
|||
if not _hid.write(handle, wdata):
|
||||
_l.warn("(%d) write failed, assuming receiver %x no longer available", devnumber, handle)
|
||||
close(handle)
|
||||
raise E.NoReceiver
|
||||
raise _NoReceiver
|
||||
|
||||
|
||||
def read(handle, timeout=DEFAULT_TIMEOUT):
|
||||
|
@ -199,7 +200,7 @@ def read(handle, timeout=DEFAULT_TIMEOUT):
|
|||
if data is None:
|
||||
_l.warn("(-) read failed, assuming receiver %x no longer available", handle)
|
||||
close(handle)
|
||||
raise E.NoReceiver
|
||||
raise _NoReceiver
|
||||
|
||||
if data:
|
||||
if len(data) < _MIN_REPLY_SIZE:
|
||||
|
@ -274,11 +275,11 @@ def request(handle, devnumber, feature_index_function, params=b'', features=None
|
|||
if reply_code == 0x11 and reply_data[0] == b'\xFF' and reply_data[1:3] == feature_index_function:
|
||||
# the feature call returned with an error
|
||||
error_code = ord(reply_data[3])
|
||||
_l.warn("(%d) request feature call error %d = %s: %s", devnumber, error_code, C.ERROR_NAME[error_code], _hexlify(reply_data))
|
||||
_l.warn("(%d) request feature call error %d = %s: %s", devnumber, error_code, ERROR_NAME[error_code], _hexlify(reply_data))
|
||||
feature_index = ord(feature_index_function[:1])
|
||||
feature_function = feature_index_function[1:2]
|
||||
feature = None if features is None else features[feature_index] if feature_index < len(features) else None
|
||||
raise E.FeatureCallError(devnumber, feature, feature_index, feature_function, error_code, reply_data)
|
||||
raise _FeatureCallError(devnumber, feature, feature_index, feature_function, error_code, reply_data)
|
||||
|
||||
if reply_code == 0x11 and reply_data[:2] == feature_index_function:
|
||||
# a matching reply
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
#
|
||||
|
||||
from binascii import hexlify as _hexlify
|
||||
from collections import namedtuple
|
||||
|
||||
|
||||
class FallbackDict(dict):
|
||||
|
@ -21,20 +22,18 @@ def list2dict(values_list):
|
|||
return dict(zip(range(0, len(values_list)), values_list))
|
||||
|
||||
|
||||
from collections import namedtuple
|
||||
|
||||
"""Tuple returned by list_devices and find_device_by_name."""
|
||||
AttachedDeviceInfo = namedtuple('AttachedDeviceInfo', [
|
||||
'handle',
|
||||
'number',
|
||||
'type',
|
||||
'kind',
|
||||
'name',
|
||||
'features'])
|
||||
|
||||
"""Firmware information."""
|
||||
FirmwareInfo = namedtuple('FirmwareInfo', [
|
||||
'level',
|
||||
'type',
|
||||
'kind',
|
||||
'name',
|
||||
'version',
|
||||
'build',
|
||||
|
@ -49,6 +48,7 @@ ReprogrammableKeyInfo = namedtuple('ReprogrammableKeyInfo', [
|
|||
'task_name',
|
||||
'flags'])
|
||||
|
||||
|
||||
class Packet(namedtuple('Packet', ['code', 'devnumber', 'data'])):
|
||||
def __str__(self):
|
||||
return 'Packet(0x%02x,%d,%s)' % (self.code, self.devnumber, '' if self.data is None else _hexlify(self.data))
|
||||
|
|
|
@ -48,17 +48,17 @@ FEATURE_NAME[FEATURE.SOLAR_CHARGE] = 'SOLAR_CHARGE'
|
|||
FEATURE_FLAGS = { 0x20: 'internal', 0x40: 'hidden', 0x80: 'obsolete' }
|
||||
|
||||
|
||||
_DEVICE_TYPES = ('Keyboard', 'Remote Control', 'NUMPAD', 'Mouse',
|
||||
'Touchpad', 'Trackball', 'Presenter', 'Receiver')
|
||||
_DEVICE_KINDS = ('keyboard', 'remote control', 'numpad', 'mouse',
|
||||
'touchpad', 'trackball', 'presenter', 'receiver')
|
||||
|
||||
"""Possible types of devices connected to an UR."""
|
||||
DEVICE_TYPE = FallbackDict(lambda x: 'unknown', list2dict(_DEVICE_TYPES))
|
||||
DEVICE_KIND = FallbackDict(lambda x: 'unknown', list2dict(_DEVICE_KINDS))
|
||||
|
||||
|
||||
_FIRMWARE_TYPES = ('Main (HID)', 'Bootloader', 'Hardware', 'Other')
|
||||
_FIRMWARE_KINDS = ('Main (HID)', 'Bootloader', 'Hardware', 'Other')
|
||||
|
||||
"""Names of different firmware levels possible, indexed by level."""
|
||||
FIRMWARE_TYPE = FallbackDict(lambda x: 'Unknown', list2dict(_FIRMWARE_TYPES))
|
||||
FIRMWARE_KIND = FallbackDict(lambda x: 'Unknown', list2dict(_FIRMWARE_KINDS))
|
||||
|
||||
|
||||
_BATTERY_STATUSES = ('Discharging (in use)', 'Recharging', 'Almost full',
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
# Exceptions that may be raised by this API.
|
||||
#
|
||||
|
||||
from . import constants as C
|
||||
from .constants import (FEATURE_NAME, ERROR_NAME)
|
||||
|
||||
|
||||
class NoReceiver(Exception):
|
||||
|
@ -16,21 +16,21 @@ class NoReceiver(Exception):
|
|||
class FeatureNotSupported(Exception):
|
||||
"""Raised when trying to request a feature not supported by the device."""
|
||||
def __init__(self, devnumber, feature):
|
||||
super(FeatureNotSupported, self).__init__(devnumber, feature, C.FEATURE_NAME[feature])
|
||||
super(FeatureNotSupported, self).__init__(devnumber, feature, FEATURE_NAME[feature])
|
||||
self.devnumber = devnumber
|
||||
self.feature = feature
|
||||
self.feature_name = C.FEATURE_NAME[feature]
|
||||
self.feature_name = FEATURE_NAME[feature]
|
||||
|
||||
|
||||
class FeatureCallError(Exception):
|
||||
"""Raised if the device replied to a feature call with an error."""
|
||||
def __init__(self, devnumber, feature, feature_index, feature_function, error_code, data=None):
|
||||
super(FeatureCallError, self).__init__(devnumber, feature, feature_index, feature_function, error_code, C.ERROR_NAME[error_code])
|
||||
super(FeatureCallError, self).__init__(devnumber, feature, feature_index, feature_function, error_code, ERROR_NAME[error_code])
|
||||
self.devnumber = devnumber
|
||||
self.feature = feature
|
||||
self.feature_name = None if feature is None else C.FEATURE_NAME[feature]
|
||||
self.feature_name = None if feature is None else FEATURE_NAME[feature]
|
||||
self.feature_index = feature_index
|
||||
self.feature_function = feature_function
|
||||
self.error_code = error_code
|
||||
self.error_string = C.ERROR_NAME[error_code]
|
||||
self.error_string = ERROR_NAME[error_code]
|
||||
self.data = data
|
||||
|
|
|
@ -7,8 +7,8 @@ from threading import (Thread, Event, Lock)
|
|||
# from time import sleep as _sleep
|
||||
|
||||
from . import base as _base
|
||||
from . import exceptions as E
|
||||
from .common import Packet
|
||||
from .exceptions import NoReceiver as _NoReceiver
|
||||
from .common import Packet as _Packet
|
||||
|
||||
# for both Python 2 and 3
|
||||
try:
|
||||
|
@ -17,95 +17,109 @@ except ImportError:
|
|||
from queue import Queue
|
||||
|
||||
|
||||
_LOG_LEVEL = 4
|
||||
_LOG_LEVEL = 6
|
||||
_l = _Logger('lur.listener')
|
||||
|
||||
|
||||
_READ_EVENT_TIMEOUT = int(_base.DEFAULT_TIMEOUT / 4) # ms
|
||||
|
||||
|
||||
def _callback_caller(listener, callback):
|
||||
# _l.log(_LOG_LEVEL, "%s starting callback caller", listener)
|
||||
while listener._active or not listener.events.empty():
|
||||
event = listener.events.get()
|
||||
_l.log(_LOG_LEVEL, "%s delivering event %s", listener, event)
|
||||
def _event_dispatch(listener, callback):
|
||||
# _l.log(_LOG_LEVEL, "starting dispatch")
|
||||
while listener._active: # or not listener._events.empty():
|
||||
event = listener._events.get()
|
||||
_l.log(_LOG_LEVEL, "delivering event %s", event)
|
||||
try:
|
||||
callback.__call__(event)
|
||||
callback(event)
|
||||
except:
|
||||
_l.exception("callback for %s", event)
|
||||
# _l.log(_LOG_LEVEL, "%s stopped callback caller", listener)
|
||||
# _l.log(_LOG_LEVEL, "stopped dispatch")
|
||||
|
||||
|
||||
class EventsListener(Thread):
|
||||
"""Listener thread for events from the Unifying Receiver.
|
||||
|
||||
Incoming events (reply_code, devnumber, data) will be passed to the callback
|
||||
function in sequence, by a separate thread.
|
||||
Incoming packets will be passed to the callback function in sequence, by a
|
||||
separate thread.
|
||||
|
||||
While this listener is running, you should use the request() method to make
|
||||
regular UR API calls, otherwise the expected API replies are most likely to
|
||||
be captured by the listener and delivered as events to the callback.
|
||||
While this listener is running, you must use the request() method to make
|
||||
regular UR API calls; otherwise the expected API replies are most likely to
|
||||
be captured by the listener and delivered to the callback.
|
||||
"""
|
||||
def __init__(self, receiver, events_callback):
|
||||
super(EventsListener, self).__init__(group='Unifying Receiver', name='Events-%x' % receiver)
|
||||
def __init__(self, receiver_handle, events_callback):
|
||||
super(EventsListener, self).__init__(group='Unifying Receiver', name='%s-%x' % (self.__class__.__name__, receiver_handle))
|
||||
|
||||
self.daemon = True
|
||||
self._active = False
|
||||
|
||||
self.receiver = receiver
|
||||
self._handle = receiver_handle
|
||||
|
||||
self.task = None
|
||||
self.task_processing = Lock()
|
||||
self.task_reply = None
|
||||
self.task_done = Event()
|
||||
self._task = None
|
||||
self._task_processing = Lock()
|
||||
self._task_reply = None
|
||||
self._task_done = Event()
|
||||
|
||||
self.events = Queue(32)
|
||||
self._events = Queue(32)
|
||||
_base.unhandled_hook = self._unhandled
|
||||
|
||||
self.event_caller = Thread(group='Unifying Receiver', name='Callback-%x' % receiver, target=_callback_caller, args=(self, events_callback))
|
||||
self.event_caller.daemon = True
|
||||
|
||||
self.__str_cached = 'Events(%x)' % self.receiver
|
||||
self._dispatcher = Thread(group='Unifying Receiver',
|
||||
name='%s-%x-dispatch' % (self.__class__.__name__, receiver_handle),
|
||||
target=_event_dispatch, args=(self, events_callback))
|
||||
self._dispatcher.daemon = True
|
||||
|
||||
def run(self):
|
||||
self._active = True
|
||||
_l.log(_LOG_LEVEL, "%s started", self)
|
||||
_l.log(_LOG_LEVEL, "started")
|
||||
|
||||
self.__str_cached = 'Events(%x:active)' % self.receiver
|
||||
self.event_caller.start()
|
||||
|
||||
last_hook = _base.unhandled_hook
|
||||
_base.unhandled_hook = self._unhandled
|
||||
self._dispatcher.start()
|
||||
|
||||
while self._active:
|
||||
event = None
|
||||
try:
|
||||
event = _base.read(self.receiver, _READ_EVENT_TIMEOUT)
|
||||
except E.NoReceiver:
|
||||
self.receiver = 0
|
||||
_l.warn("%s receiver disconnected", self)
|
||||
self.events.put(Packet(0xFF, 0xFF, None))
|
||||
event = _base.read(self._handle, _READ_EVENT_TIMEOUT)
|
||||
except _NoReceiver:
|
||||
self._handle = 0
|
||||
_l.warn("receiver disconnected")
|
||||
self._events.put(_Packet(0xFF, 0xFF, None))
|
||||
self._active = False
|
||||
break
|
||||
|
||||
if event:
|
||||
_l.log(_LOG_LEVEL, "%s queueing event %s", self, event)
|
||||
self.events.put(Packet(*event))
|
||||
_l.log(_LOG_LEVEL, "queueing event %s", event)
|
||||
self._events.put(_Packet(*event))
|
||||
|
||||
if self.task:
|
||||
task, self.task = self.task, None
|
||||
self.task_reply = self._make_request(*task)
|
||||
self.task_done.set()
|
||||
if self._task:
|
||||
(api_function, args, kwargs), self._task = self._task, None
|
||||
# _l.log(_LOG_LEVEL, "calling '%s.%s' with %s, %s", api_function.__module__, api_function.__name__, args, kwargs)
|
||||
try:
|
||||
self._task_reply = api_function.__call__(self._handle, *args, **kwargs)
|
||||
except _NoReceiver as nr:
|
||||
self._handle = 0
|
||||
_l.warn("receiver disconnected")
|
||||
self._events.put(_Packet(0xFF, 0xFF, None))
|
||||
self._task_reply = nr
|
||||
self._active = False
|
||||
break
|
||||
except Exception as e:
|
||||
# _l.exception("task %s.%s", api_function.__module__, api_function.__name__)
|
||||
self._task_reply = e
|
||||
finally:
|
||||
self._task_done.set()
|
||||
|
||||
_base.close(self.receiver)
|
||||
self.__str_cached = 'Events(%x)' % self.receiver
|
||||
|
||||
_base.unhandled_hook = last_hook
|
||||
_base.close(self._handle)
|
||||
|
||||
def stop(self):
|
||||
"""Tells the listener to stop as soon as possible."""
|
||||
if self._active:
|
||||
_l.log(_LOG_LEVEL, "stopping %s", self)
|
||||
_l.log(_LOG_LEVEL, "stopping")
|
||||
self._active = False
|
||||
# wait for the receiver handle to be closed
|
||||
self.join()
|
||||
|
||||
@property
|
||||
def handle(self):
|
||||
return self._handle
|
||||
|
||||
def request(self, api_function, *args, **kwargs):
|
||||
"""Make an UR API request through this listener's receiver.
|
||||
|
||||
|
@ -114,37 +128,24 @@ class EventsListener(Thread):
|
|||
"""
|
||||
# _l.log(_LOG_LEVEL, "%s request '%s.%s' with %s, %s", self, api_function.__module__, api_function.__name__, args, kwargs)
|
||||
|
||||
if not self._active:
|
||||
return None
|
||||
# if not self._active:
|
||||
# return None
|
||||
|
||||
with self.task_processing:
|
||||
self.task_done.clear()
|
||||
self.task = (api_function, args, kwargs)
|
||||
self.task_done.wait()
|
||||
reply, self.task_reply = self.task_reply, None
|
||||
with self._task_processing:
|
||||
self._task_done.clear()
|
||||
self._task = (api_function, args, kwargs)
|
||||
self._task_done.wait()
|
||||
reply, self._task_reply = self._task_reply, None
|
||||
|
||||
# _l.log(_LOG_LEVEL, "%s request '%s.%s' => %s", self, api_function.__module__, api_function.__name__, repr(reply))
|
||||
if isinstance(reply, Exception):
|
||||
raise reply
|
||||
return reply
|
||||
|
||||
def _make_request(self, api_function, args, kwargs):
|
||||
_l.log(_LOG_LEVEL, "%s calling '%s.%s' with %s, %s", self, api_function.__module__, api_function.__name__, args, kwargs)
|
||||
try:
|
||||
return api_function.__call__(self.receiver, *args, **kwargs)
|
||||
except E.NoReceiver as nr:
|
||||
self.task_reply = nr
|
||||
self._active = False
|
||||
except Exception as e:
|
||||
self.task_reply = e
|
||||
|
||||
def _unhandled(self, reply_code, devnumber, data):
|
||||
event = Packet(reply_code, devnumber, data)
|
||||
# _l.log(_LOG_LEVEL, "%s queueing unhandled event %s", self, event)
|
||||
self.events.put(event)
|
||||
|
||||
def __str__(self):
|
||||
return self.__str_cached
|
||||
event = _Packet(reply_code, devnumber, data)
|
||||
# _l.log(_LOG_LEVEL, "queueing unhandled event %s", event)
|
||||
self._events.put(event)
|
||||
|
||||
def __nonzero__(self):
|
||||
return self._active and self.receiver
|
||||
return self._active and self._handle
|
||||
|
|
|
@ -91,12 +91,12 @@ class Test_UR_API(unittest.TestCase):
|
|||
for fw in d_firmware:
|
||||
self.assertIsInstance(fw, FirmwareInfo)
|
||||
|
||||
def test_52_get_device_type(self):
|
||||
def test_52_get_device_kind(self):
|
||||
self._check(check_features=True)
|
||||
|
||||
d_type = api.get_device_type(self.handle, self.device, self.features)
|
||||
self.assertIsNotNone(d_type, "failed to get device type")
|
||||
self.assertGreater(len(d_type), 0, "empty device type")
|
||||
d_kind = api.get_device_kind(self.handle, self.device, self.features)
|
||||
self.assertIsNotNone(d_kind, "failed to get device kind")
|
||||
self.assertGreater(len(d_kind), 0, "empty device kind")
|
||||
|
||||
def test_55_get_device_name(self):
|
||||
self._check(check_features=True)
|
||||
|
|
|
@ -31,12 +31,12 @@ def scan_devices(receiver):
|
|||
return
|
||||
|
||||
for devinfo in devices:
|
||||
print ("Device [%d] %s (%s)" % (devinfo.number, devinfo.name, devinfo.type))
|
||||
print ("Device [%d] %s (%s)" % (devinfo.number, devinfo.name, devinfo.kind))
|
||||
# print " Protocol %s" % devinfo.protocol
|
||||
|
||||
firmware = api.get_device_firmware(receiver, devinfo.number, features=devinfo.features)
|
||||
for fw in firmware:
|
||||
print (" %s firmware: %s version %s build %d" % (fw.type, fw.name, fw.version, fw.build))
|
||||
print (" %s firmware: %s version %s build %d" % (fw.kind, fw.name, fw.version, fw.build))
|
||||
|
||||
for index in range(0, len(devinfo.features)):
|
||||
feature = devinfo.features[index]
|
||||
|
@ -77,3 +77,11 @@ if __name__ == '__main__':
|
|||
break
|
||||
else:
|
||||
print ("!! Logitech Unifying Receiver not found.")
|
||||
|
||||
|
||||
# import pyudev
|
||||
# ctx = pyudev.Context()
|
||||
# m = pyudev.Monitor.from_netlink(ctx)
|
||||
# m.filter_by(subsystem='hid')
|
||||
# for action, device in m:
|
||||
# print '%s: %s' % (action, device)
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 2.6 KiB |
Loading…
Reference in New Issue