diff --git a/README.md b/README.md index af4bd8f..3762a1a 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ pip install remarkable-mouse remouse ``` -By default, `10.11.99.1` is used as the address. Seems to work pretty well wirelessly, too. By default ssh-agent is used to authenticate if it is available, otherwise you are asked for your password. +By default, `10.11.99.1` is used as the address. Seems to work pretty well wirelessly, too. By default ssh-agent is used to authenticate if it is available, otherwise you are asked for your password. Find your password in the reMarkable's [settings menu](https://remarkablewiki.com/tech/ssh). # Examples diff --git a/remarkable_mouse/evdev.py b/remarkable_mouse/evdev.py index 168bb17..7903d3f 100644 --- a/remarkable_mouse/evdev.py +++ b/remarkable_mouse/evdev.py @@ -3,9 +3,11 @@ import struct import subprocess from screeninfo import get_monitors import time +from itertools import cycle +from socket import timeout as TimeoutError logging.basicConfig(format='%(message)s') -log = logging.getLogger(__name__) +log = logging.getLogger('remouse') # Maximum value that can be reported by the Wacom driver for the X axis MAX_ABS_X = 20967 @@ -13,6 +15,11 @@ MAX_ABS_X = 20967 # Maximum value that can be reported by the Wacom driver for the Y axis MAX_ABS_Y = 15725 +# Maximum value that can be reported by the cyttsp5_mt driver for the X axis +MT_MAX_ABS_X = 767 + +# Maximum value that can be reported by the cyttsp5_mt driver for the Y axis +MT_MAX_ABS_Y = 1023 def create_local_device(): """ @@ -26,23 +33,70 @@ def create_local_device(): device = libevdev.Device() # Set device properties to emulate those of Wacom tablets - device.name = 'reMarkable tablet' + device.name = 'reMarkable pen' device.id = { - 'bustype': 0x18, # i2c + 'bustype': 0x03, # usb 'vendor': 0x056a, # wacom 'product': 0, 'version': 54 } + # ----- Buttons ----- + # Enable buttons supported by the digitizer device.enable(libevdev.EV_KEY.BTN_TOOL_PEN) device.enable(libevdev.EV_KEY.BTN_TOOL_RUBBER) device.enable(libevdev.EV_KEY.BTN_TOUCH) device.enable(libevdev.EV_KEY.BTN_STYLUS) device.enable(libevdev.EV_KEY.BTN_STYLUS2) + device.enable(libevdev.EV_KEY.BTN_0) + device.enable(libevdev.EV_KEY.BTN_1) + device.enable(libevdev.EV_KEY.BTN_2) - # Enable position, tilt, distance and pressure change events + # ----- Touch ----- + + # Enable Touch input + device.enable( + libevdev.EV_ABS.ABS_MT_POSITION_X, + libevdev.InputAbsInfo(minimum=0, maximum=MT_MAX_ABS_X, resolution=2531) # resolution correct? + ) + device.enable( + libevdev.EV_ABS.ABS_MT_POSITION_Y, + libevdev.InputAbsInfo(minimum=0, maximum=MT_MAX_ABS_Y, resolution=2531) # resolution correct? + ) + device.enable( + libevdev.EV_ABS.ABS_MT_PRESSURE, + libevdev.InputAbsInfo(minimum=0, maximum=255) + ) + device.enable( + libevdev.EV_ABS.ABS_MT_TOUCH_MAJOR, + libevdev.InputAbsInfo(minimum=0, maximum=255) + ) + device.enable( + libevdev.EV_ABS.ABS_MT_TOUCH_MINOR, + libevdev.InputAbsInfo(minimum=0, maximum=255) + ) + device.enable( + libevdev.EV_ABS.ABS_MT_ORIENTATION, + libevdev.InputAbsInfo(minimum=-127, maximum=127) + ) + device.enable( + libevdev.EV_ABS.ABS_MT_SLOT, + libevdev.InputAbsInfo(minimum=0, maximum=31) + ) + device.enable( + libevdev.EV_ABS.ABS_MT_TOOL_TYPE, + libevdev.InputAbsInfo(minimum=0, maximum=1) + ) + device.enable( + libevdev.EV_ABS.ABS_MT_TRACKING_ID, + libevdev.InputAbsInfo(minimum=0, maximum=65535) + ) + + # ----- Pen ----- + + # Enable pen input, tilt and pressure device.enable( libevdev.EV_ABS.ABS_X, libevdev.InputAbsInfo( @@ -61,38 +115,26 @@ def create_local_device(): ) device.enable( libevdev.EV_ABS.ABS_PRESSURE, - libevdev.InputAbsInfo( - minimum=0, - maximum=4095 - ) + libevdev.InputAbsInfo(minimum=0, maximum=4095) ) device.enable( libevdev.EV_ABS.ABS_DISTANCE, - libevdev.InputAbsInfo( - minimum=0, - maximum=255 - ) + libevdev.InputAbsInfo(minimum=0, maximum=255) ) device.enable( libevdev.EV_ABS.ABS_TILT_X, - libevdev.InputAbsInfo( - minimum=-9000, - maximum=9000 - ) + libevdev.InputAbsInfo(minimum=-9000, maximum=9000) ) device.enable( libevdev.EV_ABS.ABS_TILT_Y, - libevdev.InputAbsInfo( - minimum=-9000, - maximum=9000 - ) + libevdev.InputAbsInfo(minimum=-9000, maximum=9000) ) return device.create_uinput_device() -# remap screen coordinates to wacom coordinates -def remap(x, y, wacom_width, wacom_height, monitor_width, +# map computer screen coordinates to rM pen coordinates +def map_comp2pen(x, y, wacom_width, wacom_height, monitor_width, monitor_height, mode, orientation=None): if orientation in ('bottom', 'top'): @@ -113,86 +155,175 @@ def remap(x, y, wacom_width, wacom_height, monitor_width, scaling * (y - (monitor_height - wacom_height / scaling) / 2) ) -def pipe_device(args, remote_device, local_device): +# map computer screen coordinates to rM touch coordinates +def map_comp2touch(x, y, touch_width, touch_height, monitor_width, + monitor_height, mode, orientation=None): + + if orientation in ('left', 'right'): + x, y = y, x + monitor_width, monitor_height = monitor_height, monitor_width + + ratio_width, ratio_height = touch_width / monitor_width, touch_height / monitor_height + + if mode == 'fit': + scaling = max(ratio_width, ratio_height) + elif mode == 'fill': + scaling = min(ratio_width, ratio_height) + else: + raise NotImplementedError + + return ( + scaling * (x - (monitor_width - touch_width / scaling) / 2), + scaling * (y - (monitor_height - touch_height / scaling) / 2) + ) + +def configure_xinput(*, orientation, monitor, threshold, mode): """ - Pipe events from a remote device to a local device. + Configure screen mapping settings from rM to local machine Args: - args: argparse arguments - remote_device (paramiko.ChannelFile): read-only stream of input events - local_device: local virtual input device to write events to + orientation (str): location of charging port relative to screen + monitor (int): monitor number to use + threshold (int): pressure threshold for detecting pen + mode (str): scaling mode when mapping reMarkable pen coordinates to screen """ # give time for virtual device creation before running xinput commands time.sleep(1) + # ----- Pen ----- + # set orientation with xinput - orientation = {'left': 0, 'bottom': 1, 'top': 2, 'right': 3}[args.orientation] + orientation = {'left': 0, 'bottom': 1, 'top': 2, 'right': 3}[orientation] result = subprocess.run( - 'xinput --set-prop "reMarkable tablet stylus" "Wacom Rotation" {}'.format(orientation), + 'xinput --set-prop "reMarkable pen stylus" "Wacom Rotation" {}'.format(orientation), capture_output=True, shell=True ) if result.returncode != 0: - log.warning("Error setting orientation: %s", result.stderr) + log.warning("Error setting orientation: %s", result.stderr.decode('utf8')) # set monitor to use - monitor = get_monitors()[args.monitor] + monitor = get_monitors()[monitor] log.debug('Chose monitor: {}'.format(monitor)) result = subprocess.run( - 'xinput --map-to-output "reMarkable tablet stylus" {}'.format(monitor.name), + 'xinput --map-to-output "reMarkable pen stylus" {}'.format(monitor.name), capture_output=True, shell=True ) if result.returncode != 0: - log.warning("Error setting monitor: %s", result.stderr) + log.warning("Error setting monitor: %s", result.stderr.decode('utf8')) # set stylus pressure result = subprocess.run( - 'xinput --set-prop "reMarkable tablet stylus" "Wacom Pressure Threshold" {}'.format(args.threshold), + 'xinput --set-prop "reMarkable pen stylus" "Wacom Pressure Threshold" {}'.format(threshold), capture_output=True, shell=True ) if result.returncode != 0: - log.warning("Error setting pressure threshold: %s", result.stderr) + log.warning("Error setting pressure threshold: %s", result.stderr.decode('utf8')) # set fitting mode - min_x, min_y = remap( + min_x, min_y = map_comp2pen( 0, 0, MAX_ABS_X, MAX_ABS_Y, monitor.width, monitor.height, - args.mode, - args.orientation + mode, + orientation ) - max_x, max_y = remap( + max_x, max_y = map_comp2pen( monitor.width, monitor.height, MAX_ABS_X, MAX_ABS_Y, monitor.width, monitor.height, - args.mode, - args.orientation + mode, + orientation ) log.debug("Wacom tablet area: {} {} {} {}".format(min_x, min_y, max_x, max_y)) result = subprocess.run( - 'xinput --set-prop "reMarkable tablet stylus" "Wacom Tablet Area" \ + 'xinput --set-prop "reMarkable pen stylus" "Wacom Tablet Area" \ {} {} {} {}'.format(min_x, min_y, max_x, max_y), capture_output=True, shell=True ) if result.returncode != 0: - log.warning("Error setting fit: %s", result.stderr) + log.warning("Error setting fit: %s", result.stderr.decode('utf8')) + + # ----- Touch ----- + + # Set touch fitting mode + mt_min_x, mt_min_y = map_comp2touch( + 0, 0, + MT_MAX_ABS_X, MT_MAX_ABS_Y, monitor.width, monitor.height, + mode, + orientation + ) + mt_max_x, mt_max_y = map_comp2touch( + monitor.width, monitor.height, + MT_MAX_ABS_X, MT_MAX_ABS_Y, monitor.width, monitor.height, + mode, + orientation + ) + log.debug("Multi-touch area: {} {} {} {}".format(mt_min_x, mt_min_y, mt_max_x * 2, mt_max_y * 2)) + result = subprocess.run( + 'xinput --set-prop "reMarkable pen touch" "Wacom Tablet Area" \ + {} {} {} {}'.format(mt_min_x, mt_min_y, mt_max_x, mt_max_y), + capture_output=True, + shell=True + ) + if result.returncode != 0: + log.warning("Error setting fit: %s", result.stderr.decode('utf8')) + result = subprocess.run( # Just need to rotate the touchscreen -90 so that it matches the wacom sensor. + 'xinput --set-prop "reMarkable pen touch" "Coordinate Transformation Matrix" 0 1 0 -1 0 1 0 0 1', + capture_output=True, + shell=True + ) + if result.returncode != 0: + log.warning("Error setting orientation: %s", result.stderr.decode('utf8')) + + +def read_tablet(rm_inputs, *, orientation, monitor, threshold, mode): + """Pipe rM evdev events to local device + + Args: + rm_inputs (dictionary of paramiko.ChannelFile): dict of pen, button + and touch input streams + orientation (str): tablet orientation + monitor (int): monitor number to map to + threshold (int): pressure threshold + mode (str): mapping mode + """ + + local_device = create_local_device() + log.debug("Created virtual input device '{}'".format(local_device.devnode)) + + configure_xinput( + orientation=orientation, + monitor=monitor, + threshold=threshold, + mode=mode, + ) import libevdev - # While debug mode is active, we log events grouped together between - # SYN_REPORT events. Pending events for the next log are stored here pending_events = [] + # loop inputs forever + # for input_name, stream in cycle(rm_inputs.items()): + stream = rm_inputs['pen'] while True: - e_time, e_millis, e_type, e_code, e_value = struct.unpack('2IHHi', remote_device.read(16)) + try: + data = stream.read(16) + except TimeoutError: + continue + + e_time, e_millis, e_type, e_code, e_value = struct.unpack('2IHHi', data) + e_bit = libevdev.evbit(e_type, e_code) event = libevdev.InputEvent(e_bit, value=e_value) local_device.send_events([event]) - if args.debug: + # While debug mode is active, we log events grouped together between + # SYN_REPORT events. Pending events for the next log are stored here + if log.level == logging.DEBUG: if e_bit == libevdev.EV_SYN.SYN_REPORT: event_repr = ', '.join( '{} = {}'.format( @@ -203,4 +334,99 @@ def pipe_device(args, remote_device, local_device): log.debug('{}.{:0>6} - {}'.format(e_time, e_millis, event_repr)) pending_events = [] else: - pending_events += [event] + pending_events.append(event) + + +def create_handler(*, orientation, monitor, threshold, mode): + import libevdev + + try: + local_device = create_local_device() + log.info("Created virtual input device '{}'".format(local_device.devnode)) + except PermissionError: + log.error('Insufficient permissions for creating a virtual input device') + log.error('Make sure you run this program as root') + sys.exit(1) + + configure_xinput( + orientation=orientation, + monitor=monitor, + threshold=threshold, + mode=mode, + ) + + pending_events = [] + + + def handler(e_time, e_millis, e_type, e_code, e_value): + + e_bit = libevdev.evbit(e_type, e_code) + event = libevdev.InputEvent(e_bit, value=e_value) + local_device.send_events([event]) + + # While debug mode is active, we log events grouped together between + # SYN_REPORT events. Pending events for the next log are stored here + # if log.level == logging.DEBUG: + # if e_bit == libevdev.EV_SYN.SYN_REPORT: + # event_repr = ', '.join( + # '{} = {}'.format( + # event.code.name, + # event.value + # ) for event in pending_events + # ) + # log.debug('{}.{:0>6} - {}'.format(e_time, e_millis, event_repr)) + # pending_events = [] + # else: + # pending_events.append(event) + + return handler + + + + + + + + + + + + # pen_down = 0 + + # while True: + # for device in rm_inputs: + # try: + # e_time, e_millis, e_type, e_code, e_value = struct.unpack('2IHHi', ev.read(16)) + # e_bit = libevdev.evbit(e_type, e_code) + # except timeout: + # continue + + # if e_bit == libevdev.EV_KEY.KEY_LEFT: + # e_bit = libevdev.EV_KEY.BTN_0 + # if e_bit == libevdev.EV_KEY.KEY_HOME: + # e_bit = libevdev.EV_KEY.BTN_1 + # if e_bit == libevdev.EV_KEY.KEY_RIGHT: + # e_bit = libevdev.EV_KEY.BTN_2 + + # event = libevdev.InputEvent(e_bit, value=e_value) + + # if e_bit == libevdev.EV_KEY.BTN_TOOL_PEN: + # pen_down = e_value + + # if pen_down and 'ABS_MT' in event.code.name: # Palm rejection + # pass + # else: + # local_device.send_events([event]) + + # if args.debug: + # if e_bit == libevdev.EV_SYN.SYN_REPORT: + # event_repr = ', '.join( + # '{} = {}'.format( + # event.code.name, + # event.value + # ) for event in pending_events + # ) + # log.debug('{}.{:0>6} - {}'.format(e_time, e_millis, event_repr)) + # pending_events = [] + # else: + # pending_events += [event] diff --git a/remarkable_mouse/pynput.py b/remarkable_mouse/pynput.py index 0d91a33..d042896 100644 --- a/remarkable_mouse/pynput.py +++ b/remarkable_mouse/pynput.py @@ -3,7 +3,7 @@ import struct from screeninfo import get_monitors logging.basicConfig(format='%(message)s') -log = logging.getLogger(__name__) +log = logging.getLogger('remouse') # evtype_sync = 0 # evtype_key = 1 @@ -57,8 +57,17 @@ def remap(x, y, wacom_width, wacom_height, monitor_width, ) -def read_tablet(args, remote_device): - """Loop forever and map evdev events to mouse""" +def read_tablet(rm_inputs, *, orientation, monitor, threshold, mode): + """Loop forever and map evdev events to mouse + + Args: + rm_inputs (dictionary of paramiko.ChannelFile): dict of pen, button + and touch input streams + orientation (str): tablet orientation + monitor (int): monitor number to map to + threshold (int): pressure threshold + mode (str): mapping mode + """ from pynput.mouse import Button, Controller @@ -67,11 +76,11 @@ def read_tablet(args, remote_device): mouse = Controller() - monitor = get_monitors()[args.monitor] + monitor = get_monitors()[monitor] log.debug('Chose monitor: {}'.format(monitor)) while True: - _, _, e_type, e_code, e_value = struct.unpack('2IHHi', remote_device.read(16)) + _, _, e_type, e_code, e_value = struct.unpack('2IHHi', rm_inputs['pen'].read(16)) if e_type == e_type_abs: @@ -90,7 +99,7 @@ def read_tablet(args, remote_device): # handle draw if e_code == e_code_stylus_pressure: log.debug('\t\t{}'.format(e_value)) - if e_value > args.threshold: + if e_value > threshold: if lifted: log.debug('PRESS') lifted = False @@ -108,7 +117,7 @@ def read_tablet(args, remote_device): x, y, wacom_width, wacom_height, monitor.width, monitor.height, - args.mode, args.orientation + mode, orientation ) mouse.move( monitor.x + mapped_x - mouse.position[0], diff --git a/remarkable_mouse/remarkable_mouse.py b/remarkable_mouse/remarkable_mouse.py index 92f348d..5cb9a51 100755 --- a/remarkable_mouse/remarkable_mouse.py +++ b/remarkable_mouse/remarkable_mouse.py @@ -7,25 +7,31 @@ import os import sys import struct from getpass import getpass +from itertools import cycle import paramiko import paramiko.agent logging.basicConfig(format='%(message)s') -log = logging.getLogger(__name__) +log = logging.getLogger('remouse') +default_key = os.path.expanduser('~/.ssh/remarkable') -def open_remote_device(args, file='/dev/input/event0'): + +def open_rm_inputs(*, address, key, password): """ Open a remote input device via SSH. Args: - args: argparse arguments - file (str): path to the input device on the device + address (str): address to reMarkable + key (str, optional): path to reMarkable ssh key + password (str, optional): reMarkable ssh password Returns: - (paramiko.ChannelFile): read-only stream of input events + (paramiko.ChannelFile): read-only stream of pen events + (paramiko.ChannelFile): read-only stream of touch events + (paramiko.ChannelFile): read-only stream of button events """ - log.info("Connecting to input '{}' on '{}'".format(file, args.address)) + log.debug("Connecting to input '{}'".format(address)) client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) @@ -35,29 +41,35 @@ def open_remote_device(args, file='/dev/input/event0'): agent = paramiko.agent.Agent() - if args.key is not None: - password = None + def use_key(key): try: - pkey = paramiko.RSAKey.from_private_key_file(os.path.expanduser(args.key)) + pkey = paramiko.RSAKey.from_private_key_file(os.path.expanduser(key)) except paramiko.ssh_exception.PasswordRequiredException: passphrase = getpass( - "Enter passphrase for key '{}': ".format(os.path.expanduser(args.key)) + "Enter passphrase for key '{}': ".format(os.path.expanduser(key)) ) pkey = paramiko.RSAKey.from_private_key_file( - os.path.expanduser(args.key), + os.path.expanduser(key), password=passphrase ) - elif args.password: - password = args.password + return pkey + + if key is not None: + password = None + pkey = use_key(key) + elif os.path.exists(default_key): + password = None + pkey = use_key(default_key) + elif password: pkey = None elif not agent.get_keys(): password = getpass( - "Password for '{}': ".format(args.address) + "Password for '{}': ".format(address) ) pkey = None client.connect( - args.address, + address, username='root', password=password, pkey=pkey, @@ -68,12 +80,33 @@ def open_remote_device(args, file='/dev/input/event0'): paramiko.agent.AgentRequestHandler(session) - # Start reading events - _, stdout, _ = client.exec_command('cat ' + file) + pen_file = client.exec_command( + 'readlink -f /dev/input/touchscreen0' + )[1].read().decode('utf8').rstrip('\n') - print("connected to", args.address) + # handle both reMarkable versions + # https://github.com/Eeems/oxide/issues/48#issuecomment-690830572 + if pen_file == '/dev/input/event0': + # rM 1 + touch_file = '/dev/input/event1' + button_file = '/dev/input/event2' + else: + # rM 2 + touch_file = '/dev/input/event2' + button_file = '/dev/input/event0' - return stdout + log.debug('Pen:{}\nTouch:{}\nButton:{}'.format(pen_file, touch_file, button_file)) + + # Start reading events + pen = client.exec_command('cat ' + pen_file)[1] + touch = client.exec_command('cat ' + touch_file)[1] + button = client.exec_command('cat ' + button_file)[1] + # Skip to next input if no data available + # pen.channel.setblocking(0) + # touch.channel.setblocking(0) + # button.channel.setblocking(0) + + return {'pen': pen, 'touch': touch, 'button': button} def main(): @@ -84,39 +117,90 @@ def main(): parser.add_argument('--password', default=None, type=str, help="ssh password") parser.add_argument('--address', default='10.11.99.1', type=str, help="device address") parser.add_argument('--mode', default='fill', choices=['fit', 'fill'], help="scale setting") - parser.add_argument('--orientation', default='right', choices=['top', 'left', 'right', 'bottom'], help="position of tablet buttons") + parser.add_argument('--orientation', default='right', choices=['top', 'left', 'right', 'bottom'], help="position of charging port") parser.add_argument('--monitor', default=0, type=int, metavar='NUM', help="monitor to output to") parser.add_argument('--threshold', metavar='THRESH', default=600, type=int, help="stylus pressure threshold (default 600)") parser.add_argument('--evdev', action='store_true', default=False, help="use evdev to support pen pressure (requires root, Linux only)") args = parser.parse_args() - remote_device = open_remote_device(args) - if args.debug: - logging.getLogger('').setLevel(logging.DEBUG) log.setLevel(logging.DEBUG) - log.info('Debugging enabled...') + print('Debugging enabled...') else: log.setLevel(logging.INFO) - if args.evdev: - from remarkable_mouse.evdev import create_local_device, pipe_device + # ----- Connect to device ----- - try: - local_device = create_local_device() - log.info("Created virtual input device '{}'".format(local_device.devnode)) - except PermissionError: - log.error('Insufficient permissions for creating a virtual input device') - log.error('Make sure you run this program as root') - sys.exit(1) + rm_inputs = open_rm_inputs( + address=args.address, + key=args.key, + password=args.password, + ) + print("Connected to", args.address) + + # ----- Setup event handlers ----- - pipe_device(args, remote_device, local_device) + if args.evdev: + # from remarkable_mouse.evdev import create_local_device, configure_xinput, read_tablet + + # try: + # local_device = create_local_device() + # log.info("Created virtual input device '{}'".format(local_device.devnode)) + # except PermissionError: + # log.error('Insufficient permissions for creating a virtual input device') + # log.error('Make sure you run this program as root') + # sys.exit(1) + + # configure_xinput( + # orientation=args.orientation, + # monitor=args.monitor, + # threshold=args.threshold, + # mode=args.mode, + # ) + # read_tablet(rm_inputs, local_device) + # + # from remarkable_mouse.evdev import create_handler + from remarkable_mouse.evdev import read_tablet else: from remarkable_mouse.pynput import read_tablet - read_tablet(args, remote_device) + read_tablet( + rm_inputs, + orientation=args.orientation, + monitor=args.monitor, + threshold=args.threshold, + mode=args.mode, + ) + # from remarkable_mouse.pynput import create_handler + + # event_handler = create_handler( + # orientation=args.orientation, + # monitor=args.monitor, + # threshold=args.threshold, + # mode=args.mode, + # ) + + # ----- Handle events ----- + + # loop inputs forever + # import socket + # for name, stream in cycle(rm_inputs.items()): + # try: + # data = stream.read(16) + # except socket.timeout: + # continue + + # e_time, e_millis, e_type, e_code, e_value = struct.unpack('2IHHi', data) + + # event_handler(e_time, e_millis, e_type, e_code, e_value) + + + except PermissionError: + log.error('Insufficient permissions for creating a virtual input device') + log.error('Make sure you run this program as root') + sys.exit(1) except KeyboardInterrupt: pass except EOFError: