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 connect(self): if self.socket is None: if os.path.exists(self._path): # leftover from the previous execution os.remove(self._path) _socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) _socket.bind(self._path) _socket.listen(1) chown(self._path) logger.spam('Created socket: "%s"', self._path) self.socket = _socket self.socket.setblocking(False) existing_servers[self._path] = self incoming = len(select.select([self.socket], [], [], 0)[0]) != 0 if not incoming and self.connection is None: # no existing connection, no client attempting to connect return False if not incoming and self.connection is not None: # old connection return True if incoming: logger.spam('Incoming connection: "%s"', self._path) connection = self.socket.accept()[0] self.connection = connection self.connection.setblocking(False) return True
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)
def check_add_row(self): """Ensure that one empty row is available at all times.""" rows = self.get('key_list').get_children() # verify that all mappings are displayed. # One of them is possibly the empty row num_rows = len(rows) num_maps = len(custom_mapping) if num_rows < num_maps or num_rows > num_maps + 1: logger.error( 'custom_mapping contains %d rows, ' 'but %d are displayed', len(custom_mapping), num_rows) logger.spam('Mapping %s', list(custom_mapping)) logger.spam('Rows %s', [(row.get_key(), row.get_character()) for row in rows]) # iterating over that 10 times per second is a bit wasteful, # but the old approach which involved just counting the number of # mappings and rows didn't seem very robust. for row in rows: if row.get_key() is None or row.get_character() is None: # unfinished row found break else: self.add_empty() return True
def send(self, message): """Send jsonable messages, like numbers, strings or objects.""" dump = bytes(json.dumps((time.time(), message)), ENCODING) self.unsent.append(dump) if not self.connect(): logger.spam('Not connected') return def send_all(): while len(self.unsent) > 0: unsent = self.unsent[0] self.connection.sendall(unsent + END) # sending worked, remove message self.unsent.pop(0) # attempt sending twice in case it fails try: send_all() except BrokenPipeError: if not self.reconnect(): logger.error('%s: The other side of "%s" disappeared', type(self).__name__, self._path) return try: send_all() except BrokenPipeError as error: logger.error('%s: Failed to send via "%s": %s', type(self).__name__, self._path, error)
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 load(self, path): """Load a dumped JSON from home to overwrite the mappings. Parameters path : string Path of the preset file """ logger.info('Loading preset from "%s"', path) if not os.path.exists(path): raise FileNotFoundError( f'Tried to load non-existing preset "{path}"' ) self.clear_config() with open(path, 'r') as file: preset_dict = json.load(file) if not isinstance(preset_dict.get('mapping'), dict): logger.error( 'Expected mapping to be a dict, but was %s. ' 'Invalid preset config at "%s"', preset_dict.get('mapping'), path ) return for key, symbol in preset_dict['mapping'].items(): try: key = Key(*[ split_key(chunk) for chunk in key.split('+') if chunk.strip() != '' ]) except ValueError as error: logger.error(str(error)) continue if None in key: continue logger.spam('%s maps to %s', key, symbol) self._mapping[key] = symbol # add any metadata of the mapping for key in preset_dict: if key == 'mapping': continue self._config[key] = preset_dict[key] self.changed = False self.num_saved_keys = len(self)
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 connect(self): if self.socket is not None: return True try: _socket = socket.socket(socket.AF_UNIX) _socket.connect(self._path) logger.spam('Connected to socket: "%s"', self._path) _socket.setblocking(False) except Exception as error: logger.spam('Failed to connect to "%s": "%s"', self._path, error) return False self.socket = _socket self.connection = _socket existing_clients[self._path] = self return True
def log(key, msg, *args): """Function that logs nicely formatted spams.""" if not is_debug(): return msg = msg % args str_key = str(key) str_key = str_key.replace(',)', ')') spacing = ' ' + '-' * max(0, 30 - len(str_key)) if len(spacing) == 1: spacing = '' msg = f'{str_key}{spacing} {msg}' logger.spam(msg) return msg
def manage(self): """Manage the dictionary, handle read and write requests.""" shared_dict = dict() while True: message = self.pipe[0].recv() logger.spam('SharedDict got %s', message) if message[0] == 'stop': return if message[0] == 'set': shared_dict[message[1]] = message[2] if message[0] == 'get': self.pipe[0].send(shared_dict.get(message[1])) if message[0] == 'ping': self.pipe[0].send('pong')
def parse(macro, mapping, return_errors=False): """parse and generate a _Macro that can be run as often as you want. If it could not be parsed, possibly due to syntax errors, will log the error and return None. Parameters ---------- macro : string "r(3, k(a).w(10))" "r(2, k(a).k(-)).k(b)" "w(1000).m(Shift_L, r(2, k(a))).w(10, 20).k(b)" mapping : Mapping The preset object, needed for some config stuff return_errors : bool If True, returns errors as a string or None if parsing worked. If False, returns the parsed macro. """ macro = handle_plus_syntax(macro) # whitespaces, tabs, newlines and such don't serve a purpose. make # the log output clearer and the parsing easier. macro = re.sub(r'\s', '', macro) if '"' in macro or "'" in macro: logger.info('Quotation marks in macros are not needed') macro = macro.replace('"', '').replace("'", '') if return_errors: logger.spam('checking the syntax of %s', macro) else: logger.spam('preparing macro %s for later execution', macro) try: macro_object = _parse_recurse(macro, mapping) return macro_object if not return_errors else None except Exception as error: logger.error('Failed to parse macro "%s": %s', macro, error.__repr__()) # print the traceback in case this is a bug of key-mapper logger.debug(''.join(traceback.format_tb(error.__traceback__)).strip()) return str(error) if return_errors else None
def recv(self): """Read an object from the pipe or None if nothing available. Doesn't transmit pickles, to avoid injection attacks on the privileged helper. Only messages that can be converted to json are allowed. """ if len(self._unread) > 0: return self._unread.pop(0) line = self._handles[0].readline() if len(line) == 0: return None parsed = json.loads(line) if parsed[0] < self._created_at and os.environ.get('UNITTEST'): # important to avoid race conditions between multiple unittests, # for example old terminate messages reaching a new instance of # the helper. logger.spam('Ignoring old message %s', parsed) return None return parsed[1]
def __init__(self, path): """Create a pipe, or open it if it already exists.""" self._path = path self._unread = [] self._created_at = time.time() paths = (f'{path}r', f'{path}w') mkdir(os.path.dirname(path)) if not os.path.exists(paths[0]): logger.spam('Creating new pipe for "%s"', path) # The fd the link points to is closed, or none ever existed # If there is a link, remove it. if os.path.islink(paths[0]): os.remove(paths[0]) if os.path.islink(paths[1]): os.remove(paths[1]) self._fds = os.pipe() fds_dir = f'/proc/{os.getpid()}/fd/' chown(f'{fds_dir}{self._fds[0]}') chown(f'{fds_dir}{self._fds[1]}') # to make it accessible by path constants, create symlinks os.symlink(f'{fds_dir}{self._fds[0]}', paths[0]) os.symlink(f'{fds_dir}{self._fds[1]}', paths[1]) else: logger.spam('Using existing pipe for "%s"', path) # thanks to os.O_NONBLOCK, readline will return b'' when there # is nothing to read self._fds = (os.open(paths[0], os.O_RDONLY | os.O_NONBLOCK), os.open(paths[1], os.O_WRONLY | os.O_NONBLOCK)) self._handles = (open(self._fds[0], 'r'), open(self._fds[1], 'w'))
def _receive_new_messages(self): if not self.connect(): logger.spam('Not connected') return messages = b'' attempts = 0 while True: try: chunk = self.connection.recvmsg(4096)[0] messages += chunk if len(chunk) == 0: # select keeps telling me the socket has messages # ready to be received, and I keep getting empty # buffers. Happened during a test that ran two helper # processes without stopping the first one. attempts += 1 if attempts == 2 or not self.reconnect(): return except (socket.timeout, BlockingIOError): break split = messages.split(END) for message in split: if len(message) > 0: parsed = json.loads(message.decode(ENCODING)) if parsed[0] < self._created_at: # important to avoid race conditions between multiple # unittests, for example old terminate messages reaching # a new instance of the helper. logger.spam('Ignoring old message %s', parsed) continue self._unread.append(parsed[1])
def run(self): """Do what get_groups describes.""" # evdev needs asyncio to work loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) logger.debug('Discovering device paths') # group them together by usb device because there could be stuff like # "Logitech USB Keyboard" and "Logitech USB Keyboard Consumer Control" grouped = {} for path in evdev.list_devices(): device = evdev.InputDevice(path) if device.name == 'Power Button': continue device_type = classify(device) if device_type == CAMERA: continue # https://www.kernel.org/doc/html/latest/input/event-codes.html capabilities = device.capabilities(absinfo=False) key_capa = capabilities.get(EV_KEY) if key_capa is None and device_type != GAMEPAD: # skip devices that don't provide buttons that can be mapped continue if is_denylisted(device): continue key = get_unique_key(device) if grouped.get(key) is None: grouped[key] = [] logger.spam('Found "%s", "%s", "%s", type: %s', key, path, device.name, device_type) grouped[key].append((device.name, path, device_type)) # now write down all the paths of that group result = [] used_keys = set() for group in grouped.values(): names = [entry[0] for entry in group] devs = [entry[1] for entry in group] # generate a human readable key shortest_name = sorted(names, key=len)[0] key = shortest_name i = 2 while key in used_keys: key = f'{shortest_name} {i}' i += 1 used_keys.add(key) group = _Group( key=key, paths=devs, names=names, types=sorted( list({item[2] for item in group if item[2] != UNKNOWN}))) result.append(group.dumps()) self.pipe.send(json.dumps(result))
def read(self): """Get the newest key as Key object If the timing of two recent events is very close, prioritize key events over abs events. """ if self._pipe is None: self.fail_counter += 1 if self.fail_counter % 10 == 0: # spam less logger.debug('No pipe available to read from') return None newest_event = self.newest_event newest_time = (0 if newest_event is None else newest_event.sec + newest_event.usec / 1000000) while self._pipe[0].poll(): event = self._pipe[0].recv() without_value = (event.type, event.code) if event.value == 0: if without_value in self._unreleased: del self._unreleased[without_value] continue if without_value in self._unreleased: # no duplicate down events (gamepad triggers) continue time = event.sec + event.usec / 1000000 delta = time - newest_time if delta < FILTER_THRESHOLD: if prioritize([newest_event, event]) != event: # two events happened very close, probably some weird # spam from the device. The wacom intuos 5 adds an # ABS_MISC event to every button press, filter that out logger.spam('Ignoring event (%s, %s, %s)', event.type, event.code, event.value) continue # the previous event is ignored previous_without_value = (newest_event.type, newest_event.code) if previous_without_value in self._unreleased: del self._unreleased[previous_without_value] self._unreleased[without_value] = (event.type, event.code, event.value) newest_event = event newest_time = time if newest_event == self.newest_event: # don't return the same event twice return None self.newest_event = newest_event if len(self._unreleased) > 0: return Key(*self._unreleased.values()) # nothing return 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') # group them together by usb device because there could be stuff like # "Logitech USB Keyboard" and "Logitech USB Keyboard Consumer Control" grouped = {} for path in evdev.list_devices(): device = evdev.InputDevice(path) if device.name == 'Power Button': continue device_type = classify(device) if device_type == CAMERA: continue # https://www.kernel.org/doc/html/latest/input/event-codes.html capabilities = device.capabilities(absinfo=False) key_capa = capabilities.get(EV_KEY) if key_capa is None and device_type != GAMEPAD: # skip devices that don't provide buttons that can be mapped continue name = device.name path = device.path info = ( f'{device.info.bustype},' f'{device.info.vendor},' f'{device.info.product}' # observed a case with varying versions within a device, # so only use the other three as index ) if grouped.get(info) is None: grouped[info] = [] logger.spam('Found "%s", "%s", "%s", type: %s', info, path, name, device_type) grouped[info].append((name, path, device_type)) # 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] shortest_name = sorted(names, key=len)[0] result[shortest_name] = { 'paths': devs, 'devices': names, # sort it alphabetically to be predictable in tests 'types': sorted(list({item[2] for item in group if item[2] != UNKNOWN})) } self.pipe.send(result)
def _parse_recurse(macro, mapping, macro_instance=None, depth=0): """Handle a subset of the macro, e.g. one parameter or function call. Parameters ---------- macro : string Just like parse mapping : Mapping The preset configuration macro_instance : _Macro or None A macro instance to add tasks to depth : int """ # to anyone who knows better about compilers and thinks this is horrible: # please make a pull request. Because it probably is. # not using eval for security reasons ofc. And this syntax doesn't need # string quotes for its params. # If this gets more complicated than that I'd rather make a macro # editor GUI and store them as json. assert isinstance(macro, str) assert isinstance(depth, int) if macro == '': return None if macro_instance is None: macro_instance = _Macro(macro, mapping) else: assert isinstance(macro_instance, _Macro) macro = macro.strip() space = ' ' * depth # is it another macro? call_match = re.match(r'^(\w+)\(', macro) call = call_match[1] if call_match else None if call is not None: # available functions in the macro and the minimum and maximum number # of their parameters functions = { 'm': (macro_instance.modify, 2, 2), 'r': (macro_instance.repeat, 2, 2), 'k': (macro_instance.keycode, 1, 1), 'e': (macro_instance.event, 3, 3), 'w': (macro_instance.wait, 1, 1), 'h': (macro_instance.hold, 0, 1), 'mouse': (macro_instance.mouse, 2, 2), 'wheel': (macro_instance.wheel, 2, 2) } function = functions.get(call) if function is None: raise Exception(f'Unknown function {call}') # get all the stuff inbetween position = _count_brackets(macro) inner = macro[macro.index('(') + 1:position - 1] # split "3, k(a).w(10)" into parameters string_params = _extract_params(inner) logger.spam('%scalls %s with %s', space, call, string_params) # evaluate the params params = [ _parse_recurse(param.strip(), mapping, None, depth + 1) for param in string_params ] logger.spam('%sadd call to %s with %s', space, call, params) if len(params) < function[1] or len(params) > function[2]: if function[1] != function[2]: msg = ( f'{call} takes between {function[1]} and {function[2]}, ' f'not {len(params)} parameters') else: msg = (f'{call} takes {function[1]}, ' f'not {len(params)} parameters') raise ValueError(msg) function[0](*params) # is after this another call? Chain it to the macro_instance if len(macro) > position and macro[position] == '.': chain = macro[position + 1:] logger.spam('%sfollowed by %s', space, chain) _parse_recurse(chain, mapping, macro_instance, depth) return macro_instance # probably a parameter for an outer function try: # if possible, parse as int macro = int(macro) except ValueError: # use as string instead pass logger.spam('%s%s %s', space, type(macro), macro) return macro
def _macro_write(self, code, value, uinput): """Handler for macros.""" logger.spam('macro writes code:%s value:%d', code, value) uinput.write(EV_KEY, code, value) uinput.syn()
def _start_injecting(self): """The injection worker that keeps injecting until terminated. Stuff is non-blocking by using asyncio in order to do multiple things somewhat concurrently. Use this function as starting point in a process. It creates the loops needed to read and map events and keeps running them. """ # create a new event loop, because somehow running an infinite loop # that sleeps on iterations (ev_abs_mapper) in one process causes # another injection process to screw up reading from the grabbed # device. loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) numlock_state = is_numlock_on() loop = asyncio.get_event_loop() coroutines = [] logger.info('Starting injecting the mapping for "%s"', self.device) paths = get_devices()[self.device]['paths'] # Watch over each one of the potentially multiple devices per hardware for path in paths: source, abs_to_rel = self._prepare_device(path) if source is None: continue # each device needs own macro instances to add a custom handler logger.debug('Parsing macros for %s', path) macros = {} for key, output in self.mapping: if is_this_a_macro(output): macro = parse(output, self.mapping) if macro is None: continue for permutation in key.get_permutations(): macros[permutation.keys] = macro if len(macros) == 0: logger.debug('No macros configured') # certain capabilities can have side effects apparently. with an # EV_ABS capability, EV_REL won't move the mouse pointer anymore. # so don't merge all InputDevices into one UInput device. uinput = evdev.UInput(name=f'{DEV_NAME} {self.device}', phys=DEV_NAME, events=self._modify_capabilities( macros, source, abs_to_rel)) logger.spam('Injected capabilities for "%s": %s', path, uinput.capabilities(verbose=True)) def handler(*args, uinput=uinput): # this ensures that the right uinput is used for macro_write, # because this is within a loop self._macro_write(*args, uinput) for macro in macros.values(): macro.set_handler(handler) # actual reading of events coroutines.append( self._event_consumer(macros, source, uinput, abs_to_rel)) # mouse movement injection based on the results of the # event consumer if abs_to_rel: self.abs_state[0] = 0 self.abs_state[1] = 0 coroutines.append( ev_abs_mapper(self.abs_state, source, uinput, self.mapping)) if len(coroutines) == 0: logger.error('Did not grab any device') return coroutines.append(self._msg_listener(loop)) # set the numlock state to what it was before injecting, because # grabbing devices screws this up set_numlock(numlock_state) try: loop.run_until_complete(asyncio.gather(*coroutines)) except RuntimeError: # stopped event loop most likely pass except OSError as error: logger.error(str(error)) if len(coroutines) > 0: logger.debug('asyncio coroutines ended')