示例#1
0
    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)
示例#2
0
    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
示例#3
0
    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)
示例#4
0
    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
示例#5
0
    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)
示例#6
0
    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)
示例#7
0
    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)
示例#8
0
    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)
示例#9
0
    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
示例#10
0
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
示例#11
0
    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')
示例#12
0
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
示例#13
0
    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]
示例#14
0
    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'))
示例#15
0
    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])
示例#16
0
    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))
示例#17
0
    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
示例#18
0
    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)
示例#19
0
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
示例#20
0
 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()
示例#21
0
    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')