async def ifeq(handler): set_value = macro_variables.get(variable) logger.debug('"%s" is "%s"', variable, set_value) if set_value == value: await then.run(handler) elif otherwise is not None: await otherwise.run(handler)
def get_dbus_interface(fallback=True): """Get an interface to start and stop injecting keystrokes. Parameters ---------- fallback : bool If true, returns an instance of the daemon instead if it cannot connect """ msg = ('The daemon "key-mapper-service" is not running, mapping keys ' 'only works as long as the window is open. ' 'Try `sudo systemctl start key-mapper`') if not is_service_running(): if not fallback: logger.error('Service not running') return None logger.warning(msg) return Daemon() bus = SystemBus() try: interface = bus.get(BUS_NAME) except GLib.GError as error: logger.debug(error) if not fallback: logger.error('Failed to connect to the running service') return None logger.warning(msg) return Daemon() return interface
def test_debug(self): path = os.path.join(tmp, 'logger-test') add_filehandler(path) logger.error('abc') logger.warning('foo') logger.info('123') logger.debug('456') logger.spam('789') with open(path, 'r') as f: content = f.read().lower() self.assertIn('logger.py', content) self.assertIn('error', content) self.assertIn('abc', content) self.assertIn('warn', content) self.assertIn('foo', content) self.assertIn('info', content) self.assertIn('123', content) self.assertIn('debug', content) self.assertIn('456', content) self.assertIn('spam', content) self.assertIn('789', content)
def test_default(self): path = add_filehandler(os.path.join(tmp, 'logger-test')) update_verbosity(debug=False) logger.error('abc') logger.warning('foo') logger.info('123') logger.debug('456') logger.spam('789') with open(path, 'r') as f: content = f.read().lower() self.assertNotIn('logger.py', content) self.assertNotIn('line', content) self.assertIn('error', content) self.assertIn('abc', content) self.assertIn('warn', content) self.assertIn('foo', content) self.assertNotIn('info', content) self.assertIn('123', content) self.assertNotIn('debug', content) self.assertNotIn('456', content) self.assertNotIn('spam', content) self.assertNotIn('789', content)
def change(self, new_key, symbol, previous_key=None): """Replace the mapping of a keycode with a different one. Parameters ---------- new_key : Key symbol : string A single symbol known to xkb or linux. Examples: KEY_KP1, Shift_L, a, B, BTN_LEFT. previous_key : Key or None the previous key If not set, will not remove any previous mapping. If you recently used (1, 10, 1) for new_key and want to overwrite that with (1, 11, 1), provide (1, 10, 1) here. """ if not isinstance(new_key, Key): raise TypeError(f'Expected {new_key} to be a Key object') if symbol is None: raise ValueError('Expected `symbol` not to be None') symbol = symbol.strip() logger.debug('%s will map to "%s"', new_key, symbol) self.clear(new_key) # this also clears all equivalent keys self._mapping[new_key] = symbol if previous_key is not None: code_changed = new_key != previous_key if code_changed: # clear previous mapping of that code, because the line # representing that one will now represent a different one self.clear(previous_key) self.changed = True
def load_config(self, path=None): """Load the config from the file system. Parameters ---------- path : string or None If set, will change the path to load from and save to. """ if path is not None: if not os.path.exists(path): logger.error('Config at "%s" not found', path) return self.path = path self.clear_config() if not os.path.exists(self.path): # treated like an empty config logger.debug('Config "%s" doesn\'t exist yet', self.path) self.clear_config() self._config = copy.deepcopy(INITIAL_CONFIG) self.save_config() return with open(self.path, 'r') as file: try: self._config.update(json.load(file)) logger.info('Loaded config from "%s"', self.path) except json.decoder.JSONDecodeError as error: logger.error('Failed to parse config "%s": %s. Using defaults', self.path, str(error))
def populate_presets(self): """Show the available presets for the selected device. This will destroy unsaved changes in the custom_mapping. """ device = self.selected_device presets = get_presets(device) if len(presets) == 0: new_preset = get_available_preset_name(self.selected_device) custom_mapping.empty() path = get_preset_path(self.selected_device, new_preset) custom_mapping.save(path) presets = [new_preset] else: logger.debug('"%s" presets: "%s"', device, '", "'.join(presets)) preset_selection = self.get('preset_selection') with HandlerDisabled(preset_selection, self.on_select_preset): # otherwise the handler is called with None for each preset preset_selection.remove_all() for preset in presets: preset_selection.append(preset, preset) # and select the newest one (on the top). triggers on_select_preset preset_selection.set_active(0)
def handle_plus_syntax(macro): """transform a + b + c to m(a, m(b, m(c, h())))""" if '+' not in macro: return macro if '(' in macro or ')' in macro: logger.error('Mixing "+" and macros is unsupported: "%s"', macro) return macro chunks = [chunk.strip() for chunk in macro.split('+')] output = '' depth = 0 for chunk in chunks: if chunk == '': # invalid syntax logger.error('Invalid syntax for "%s"', macro) return macro depth += 1 output += f'm({chunk},' output += 'h()' output += depth * ')' logger.debug('Transformed "%s" to "%s"', macro, output) return output
def _handle_commands(self): """Handle all unread commands.""" # wait for something to do select.select([self._commands], [], []) while self._commands.poll(): cmd = self._commands.recv() logger.debug('Received command "%s"', cmd) if cmd == TERMINATE: logger.debug('Helper terminates') sys.exit(0) if cmd == REFRESH_GROUPS: groups.refresh() self._send_groups() continue group = groups.find(key=cmd) if group is None: groups.refresh() group = groups.find(key=cmd) if group is not None: self.group = group continue logger.error('Received unknown command "%s"', cmd)
def on_select_device(self, dropdown): """List all presets, create one if none exist yet.""" self.save_preset() if dropdown.get_active_id() == self.selected_device: return # selecting a device will also automatically select a different # preset. Prevent another unsaved-changes dialog to pop up custom_mapping.changed = False device = dropdown.get_active_id() if device is None: return logger.debug('Selecting device "%s"', device) self.selected_device = device self.selected_preset = None self.populate_presets() reader.start_reading(device) self.show_device_mapping_status()
def populate(self): """Get a mapping of all available names to their keycodes.""" logger.debug('Gathering available keycodes') self.clear() xmodmap_dict = {} try: xmodmap = subprocess.check_output( ['xmodmap', '-pke'], stderr=subprocess.STDOUT).decode() xmodmap = xmodmap self._xmodmap = re.findall(r'(\d+) = (.+)\n', xmodmap + '\n') xmodmap_dict = self._find_legit_mappings() except (subprocess.CalledProcessError, FileNotFoundError): # might be within a tty pass if USER != 'root': # write this stuff into the key-mapper config directory, because # the systemd service won't know the user sessions xmodmap path = get_config_path(XMODMAP_FILENAME) touch(path) with open(path, 'w') as file: logger.debug('Writing "%s"', path) json.dump(xmodmap_dict, file, indent=4) for name, code in xmodmap_dict.items(): self._set(name, code) for name, ecode in evdev.ecodes.ecodes.items(): if name.startswith('KEY') or name.startswith('BTN'): self._set(name, ecode) self._set(DISABLE_NAME, DISABLE_CODE)
def on_select_device(self, dropdown): """List all presets, create one if none exist yet.""" self.save_preset() if self.group and dropdown.get_active_id() == self.group.key: return # selecting a device will also automatically select a different # preset. Prevent another unsaved-changes dialog to pop up custom_mapping.changed = False group_key = dropdown.get_active_id() if group_key is None: return logger.debug('Selecting device "%s"', group_key) self.group = groups.find(key=group_key) self.preset_name = None self.populate_presets() reader.start_reading(groups.find(key=group_key)) self.show_device_mapping_status()
def _read_worker(self): """Thread that reads keycodes and buffers them into a pipe.""" # using a thread that blocks instead of read_one made it easier # to debug via the logs, because the UI was not polling properly # at some point which caused logs for events not to be written. rlist = {device.fd: device for device in self.virtual_devices} rlist[self._pipe[1]] = self._pipe[1] while True: ready = select.select(rlist, [], [])[0] for fd in ready: readable = rlist[fd] # a device or a pipe if isinstance(readable, multiprocessing.connection.Connection): msg = readable.recv() if msg == CLOSE: logger.debug('Reader stopped') return continue try: for event in rlist[fd].read(): self._consume_event(event, readable) except OSError: logger.debug('Device "%s" disappeared from the reader', rlist[fd].path) del rlist[fd]
def _consume_event(self, event, device): """Write the event code into the pipe if it is a key-down press.""" # value: 1 for down, 0 for up, 2 for hold. if self._pipe is None or self._pipe[1].closed: logger.debug('Pipe closed, reader stops.') sys.exit(0) click_events = [evdev.ecodes.BTN_LEFT, evdev.ecodes.BTN_TOOL_DOUBLETAP] if event.type == EV_KEY and event.value == 2: # ignore hold-down events return if event.type == EV_KEY and event.code in click_events: # disable mapping the left mouse button because it would break # the mouse. Also it is emitted right when focusing the row # which breaks the current workflow. return if not utils.should_map_event_as_btn(device, event, custom_mapping): return if not (event.value == 0 and event.type == EV_ABS): # avoid gamepad trigger spam logger.spam('got (%s, %s, %s)', event.type, event.code, event.value) self._pipe[1].send(event)
async def _event_consumer(self, source, forward_to): """Reads input events to inject keycodes or talk to the event_producer. Can be stopped by stopping the asyncio loop. This loop reads events from a single device only. Other devnodes may be present for the hardware device, in which case this needs to be started multiple times. Parameters ---------- source : evdev.InputDevice where to read keycodes from forward_to : evdev.UInput where to write keycodes to that were not mapped to anything. Should be an UInput with capabilities that work for all forwarded events, so ideally they should be copied from source. """ logger.debug( 'Started consumer to inject to %s, fd %s', source.path, source.fd ) gamepad = classify(source) == GAMEPAD keycode_handler = KeycodeMapper(self.context, source, forward_to) async for event in source.async_read_loop(): if self._event_producer.is_handled(event): # the event_producer will take care of it self._event_producer.notify(event) continue # for mapped stuff if utils.should_map_as_btn(event, self.context.mapping, gamepad): will_report_key_up = utils.will_report_key_up(event) keycode_handler.handle_keycode(event) if not will_report_key_up: # simulate a key-up event if no down event arrives anymore. # this may release macros, combinations or keycodes. release = evdev.InputEvent(0, 0, event.type, event.code, 0) self._event_producer.debounce( debounce_id=(event.type, event.code, event.value), func=keycode_handler.handle_keycode, args=(release, False), ticks=3, ) continue # forward the rest forward_to.write(event.type, event.code, event.value) # this already includes SYN events, so need to syn here again # This happens all the time in tests because the async_read_loop # stops when there is nothing to read anymore. Otherwise tests # would block. logger.error('The consumer for "%s" stopped early', source.path)
def on_close(self, *_): """Safely close the application.""" logger.debug('Closing window') self.window.hide() for timeout in self.timeouts: GLib.source_remove(timeout) self.timeouts = [] keycode_reader.stop_reading() Gtk.main_quit()
def stop_injecting(self, group_key): """Stop injecting the mapping for a single device.""" if self.injectors.get(group_key) is None: logger.debug( 'Tried to stop injector, but none is running for group "%s"', group_key) return self.injectors[group_key].stop_injecting() self.autoload_history.forget(group_key)
def stop_injecting(self, device): """Stop injecting the mapping for a single device.""" if self.injectors.get(device) is None: logger.debug( 'Tried to stop injector, but none is running for device "%s"', device) return self.injectors[device].stop_injecting() self.autoload_history.forget(device)
def __init__(self): """Constructs the daemon.""" logger.debug('Creating daemon') self.injectors = {} self.config_dir = None self.autoload_history = AutoloadHistory() self.refreshed_devices_at = 0 atexit.register(self.stop_all)
def on_close(self, *_): """Safely close the application.""" logger.debug('Closing window') self.save_preset() self.window.hide() for timeout in self.timeouts: GLib.source_remove(timeout) self.timeouts = [] reader.terminate() Gtk.main_quit()
def _grab_device(self, path): """Try to grab the device, return None if not needed/possible.""" try: device = evdev.InputDevice(path) except (FileNotFoundError, OSError): logger.error('Could not find "%s"', path) return None capabilities = device.capabilities(absinfo=False) needed = False for key, _ in self.context.mapping: if is_in_capabilities(key, capabilities): logger.debug('Grabbing "%s" because of "%s"', path, key) needed = True break gamepad = classify(device) == GAMEPAD if gamepad and self.context.maps_joystick(): logger.debug('Grabbing "%s" because of maps_joystick', path) needed = True if not needed: # skipping reading and checking on events from those devices # may be beneficial for performance. logger.debug('No need to grab %s', path) return None attempts = 0 while True: try: # grab to avoid e.g. the disabled keycode of 10 to confuse # X, especially when one of the buttons of your mouse also # uses 10. This also avoids having to load an empty xkb # symbols file to prevent writing any unwanted keys. device.grab() logger.debug('Grab %s', path) break except IOError as error: attempts += 1 # it might take a little time until the device is free if # it was previously grabbed. logger.debug('Failed attempts to grab %s: %d', path, attempts) if attempts >= 4: logger.error('Cannot grab %s, it is possibly in use', path) logger.error(str(error)) return None time.sleep(self.regrab_timeout) return device
def _grab_device(self, path): """Try to grab the device, return None if not needed/possible. Without grab, original events from it would reach the display server even though they are mapped. """ try: device = evdev.InputDevice(path) except (FileNotFoundError, OSError): logger.error('Could not find "%s"', path) return None capabilities = device.capabilities(absinfo=False) needed = False for key, _ in self.context.mapping: if is_in_capabilities(key, capabilities): logger.debug('Grabbing "%s" because of "%s"', path, key) needed = True break gamepad = classify(device) == GAMEPAD if gamepad and self.context.maps_joystick(): logger.debug('Grabbing "%s" because of maps_joystick', path) needed = True if not needed: # skipping reading and checking on events from those devices # may be beneficial for performance. logger.debug('No need to grab %s', path) return None attempts = 0 while True: try: device.grab() logger.debug('Grab %s', path) break except IOError as error: attempts += 1 # it might take a little time until the device is free if # it was previously grabbed. logger.debug('Failed attempts to grab %s: %d', path, attempts) if attempts >= 10: logger.error('Cannot grab %s, it is possibly in use', path) logger.error(str(error)) return None time.sleep(self.regrab_timeout) return device
def start_injecting(self, device, path, xmodmap_path=None): """Start injecting the preset for the device. Returns True on success. Parameters ---------- device : string The name of the device path : string Path to the preset. The daemon, if started via systemctl, has no knowledge of the user and their home path, so the complete absolute path needs to be provided here. xmodmap_path : string, None Path to a dump of the xkb mappings, to provide more human readable keys in the correct keyboard layout to the service. The service cannot use `xmodmap -pke` because it's running via systemd. """ if device not in get_devices(): logger.debug('Devices possibly outdated, refreshing') refresh_devices() # reload the config, since it may have been changed config.load_config() if self.injectors.get(device) is not None: self.injectors[device].stop_injecting() mapping = Mapping() try: mapping.load(path) except FileNotFoundError as error: logger.error(str(error)) return False if xmodmap_path is not None: try: with open(xmodmap_path, 'r') as file: xmodmap = json.load(file) logger.debug('Using keycodes from "%s"', xmodmap_path) system_mapping.update(xmodmap) # the service now has process wide knowledge of xmodmap # keys of the users session except FileNotFoundError: logger.error('Could not find "%s"', xmodmap_path) try: injector = KeycodeInjector(device, mapping) injector.start_injecting() self.injectors[device] = injector except OSError: return False return True
def get_data_path(filename=''): """Depending on the installation prefix, return the data dir. Since it is a nightmare to get stuff installed with pip across distros this is somewhat complicated. Ubuntu uses /usr/local/share for data_files (setup.py) and manjaro uses /usr/share. """ global logged source = None try: source = pkg_resources.require('key-mapper')[0].location # failed in some ubuntu installations except pkg_resources.DistributionNotFound: pass # depending on where this file is installed to, make sure to use the proper # prefix path for data # https://docs.python.org/3/distutils/setupscript.html?highlight=package_data#installing-additional-files # noqa pylint: disable=line-too-long data = None # python3.8/dist-packages python3.7/site-packages, /usr/share, # /usr/local/share, endless options if source and '-packages' not in source and 'python' not in source: # probably installed with -e, running from the cloned git source data = os.path.join(source, 'data') if not os.path.exists(data): if not logged: logger.debug('-e, but data missing at "%s"', data) data = None candidates = [ '/usr/share/key-mapper', '/usr/local/share/key-mapper', os.path.join(site.USER_BASE, 'share/key-mapper'), ] if data is None: # try any of the options for candidate in candidates: if os.path.exists(candidate): data = candidate break if data is None: logger.error('Could not find the application data') sys.exit(1) if not logged: logger.debug('Found data at "%s"', data) logged = True return os.path.join(data, filename)
def save_config(self): """Save the config to the file system.""" if USER == 'root': logger.debug('Skipping config file creation for the root user') return touch(self.path) with open(self.path, 'w') as file: json.dump(self._config, file, indent=4) logger.info('Saved config to %s', self.path) file.write('\n')
def clear(self): """Next time when reading don't return the previous keycode.""" logger.debug('Clearing reader') while self._results.poll(): # clear the results pipe and handle any non-event messages, # otherwise a 'groups' message might get lost message = self._results.recv() self._get_event(message) self._unreleased = {} self.previous_event = None self.previous_result = None
def run(self): """Do what get_devices describes.""" # evdev needs asyncio to work loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) logger.debug('Discovering device paths') devices = [evdev.InputDevice(path) for path in evdev.list_devices()] # group them together by usb device because there could be stuff like # "Logitech USB Keyboard" and "Logitech USB Keyboard Consumer Control" grouped = {} for device in devices: if device.name == 'Power Button': continue # only keyboard devices # https://www.kernel.org/doc/html/latest/input/event-codes.html capabilities = device.capabilities(absinfo=False) if EV_KEY not in capabilities and EV_ABS not in capabilities: # or gamepads, because they can be mapped like a keyboard continue is_gamepad = map_abs_to_rel(capabilities) name = device.name path = device.path info = str(device.info) if grouped.get(info) is None: grouped[info] = [] logger.spam('Found "%s", "%s", "%s"', info, path, name) grouped[info].append((name, path, is_gamepad)) # now write down all the paths of that group result = {} for group in grouped.values(): names = [entry[0] for entry in group] devs = [entry[1] for entry in group] is_gamepad = True in [entry[2] for entry in group] shortest_name = sorted(names, key=len)[0] result[shortest_name] = { 'paths': devs, 'devices': names, 'gamepad': is_gamepad } self.pipe.send(result)
def start_processes(self): """Start helper and daemon via pkexec to run in the background.""" # this function is overwritten in tests self.dbus = Daemon.connect() debug = ' -d' if is_debug() else '' cmd = f'pkexec key-mapper-control --command helper {debug}' logger.debug('Running `%s`', cmd) exit_code = os.system(cmd) if exit_code != 0: logger.error('Failed to pkexec the helper, code %d', exit_code) sys.exit()
def delete_preset(device, preset): """Delete one of the users presets.""" preset_path = get_preset_path(device, preset) if not os.path.exists(preset_path): logger.debug('Cannot remove non existing path "%s"', preset_path) return logger.info('Removing "%s"', preset_path) os.remove(preset_path) device_path = get_preset_path(device) if os.path.exists(device_path) and len(os.listdir(device_path)) == 0: logger.debug('Removing empty dir "%s"', device_path) os.rmdir(device_path)
async def _msg_listener(self, loop): """Wait for messages from the main process to do special stuff.""" while True: frame_available = asyncio.Event() loop.add_reader(self._msg_pipe[0].fileno(), frame_available.set) await frame_available.wait() frame_available.clear() msg = self._msg_pipe[0].recv() if msg == CLOSE: logger.debug('Received close signal') # stop the event loop and cause the process to reach its end # cleanly. Using .terminate prevents coverage from working. loop.stop() return