diff --git a/remarkable_mouse/ft5406.py b/remarkable_mouse/ft5406.py new file mode 100644 index 0000000..7f03239 --- /dev/null +++ b/remarkable_mouse/ft5406.py @@ -0,0 +1,230 @@ +import struct +from collections import namedtuple +import threading +import select +import queue +from datetime import date, datetime + +TOUCH_X = 0 +TOUCH_Y = 1 + +TouchEvent = namedtuple('TouchEvent', ('timestamp', 'type', 'code', 'value')) + +EV_SYN = 0 +EV_ABS = 3 + +ABS_X = 0 +ABS_Y = 1 + +ABS_MT_SLOT = 0x2f # 47 MT slot being modified +ABS_MT_POSITION_X = 0x35 # 53 Center X of multi touch position +ABS_MT_POSITION_Y = 0x36 # 54 Center Y of multi touch position +ABS_MT_TRACKING_ID = 0x39 # 57 Unique ID of initiated contact + +TS_RELEASE = 0 +TS_PRESS = 1 +TS_MOVE = 2 + +class Touch(object): + def __init__(self, slot, x, y): + self.slot = slot + + self._x = x + self._y = y + self.last_x = -1 + self.last_y = -1 + + self._id = -1 + self.events = [] + self.on_move = None + self.on_press = None + self.on_release = None + self.presstime = 0 + self.releasetime = 0 + + + @property + def position(self): + return (self.x, self.y) + + @property + def last_position(self): + return (self.last_x, self.last_y) + + @property + def valid(self): + return self.id > -1 + + @property + def id(self): + return self._id + + @id.setter + def id(self, value): + if value != self._id: + if value == -1 and not TS_RELEASE in self.events: + self.events.append(TS_RELEASE) + elif not TS_PRESS in self.events: + self.events.append(TS_PRESS) + + self._id = value + + @property + def x(self): + return self._x + + @x.setter + def x(self, value): + if value != self._x and not TS_MOVE in self.events: + self.events.append(TS_MOVE) + self.last_x = self._x + self._x = value + + @property + def y(self): + return self._y + + @y.setter + def y(self, value): + if value != self._y and not TS_MOVE in self.events: + self.events.append(TS_MOVE) + self.last_y = self._y + self._y = value + + def handle_events(self, touchscreen, raw_event): + """Run outstanding press/release/move events""" + for event in self.events: + if event == TS_MOVE and callable(self.on_move): + self.on_move(event, self, touchscreen, raw_event) + if event == TS_PRESS and callable(self.on_press): + self.presstime = datetime.now().timestamp() + touchscreen.last_fingers = touchscreen.fingers + touchscreen.fingers+=1 + self.on_press(event, self, touchscreen, raw_event) + if event == TS_RELEASE and callable(self.on_release): + self.releasetime = datetime.now().timestamp() + touchscreen.last_fingers = touchscreen.fingers + touchscreen.fingers-=1 + self.on_release(event, self, touchscreen, raw_event) + self.events = [] + + +class Touches(list): + @property + def valid(self): + return [touch for touch in self if touch.valid] + +class Touchscreen(object): + EVENT_FORMAT = str('2IHHi') + EVENT_SIZE = struct.calcsize(EVENT_FORMAT) + + + def __init__(self,f_channel, f_stream): + self._running = False + self._thread = None + self._f_poll = select.poll() + + self._f_device = f_channel + self._f_stream = f_stream + + self._f_poll.register(self._f_device, select.POLLIN) + self.touches = Touches([Touch(indx, 0, 0) for indx in range(11)]) # sometimes it sends an 11th finger + self._event_queue = queue.Queue() + self._touch_slot = 0 + self.fingers = 0 + self.last_fingers = 0 + self.last_timestamp = {} + self.current_timestamp = {} + + def update_timestamp(self, event): + ts = (datetime.now().timestamp()) + self.last_timestamp[event] = self.current_timestamp.get(event,0) + self.current_timestamp[event] = ts + + + def get_delta_time(self, event): + return self.current_timestamp.get(event,0) - self.last_timestamp.get(event,0) + + def _run(self): + self._running = True + while self._running: + self.poll() + + + def run(self): + if self._thread is not None: + return + + self._thread = threading.Thread(target=self._run) + self._thread.start() + + def stop(self): + if self._thread is None: + return + + self._running = False + self._thread.join() + self._thread = None + + @property + def _current_touch(self): + return self.touches[self._touch_slot] + + def close(self): + self._f_device.close() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, exc_tb): + self.close() + + def __iter__(self): + pass + + def _lazy_read(self): + while self._wait_for_events(): + event = self._f_stream.read(self.EVENT_SIZE) + if not event: + break + yield event + + def _get_pending_events(self): + for event in self._lazy_read(): + (tv_sec, tv_usec, type, code, value) = struct.unpack(self.EVENT_FORMAT, event) + ts = tv_sec + (tv_usec / 1000000) + event = TouchEvent(ts, type, code, value) + self._event_queue.put(event) + + def _wait_for_events(self, timeout=2): + return self._f_poll.poll(timeout) + + def poll(self): + self._get_pending_events() + + while not self._event_queue.empty(): + event = self._event_queue.get() + self._event_queue.task_done() + if event.type == EV_ABS: # Absolute cursor position + if event.code == ABS_MT_SLOT: + self._touch_slot = event.value + + if event.code == ABS_MT_TRACKING_ID: + self._current_touch.id = event.value + + if event.code == ABS_MT_POSITION_X: + self._current_touch.x = event.value + + if event.code == ABS_MT_POSITION_Y: + self._current_touch.y = event.value + + if event.type == EV_SYN: # Sync + for touch in self.touches: + touch.handle_events(self,event) + return self.touches + + return [] + + + def read(self): + return next(iter(self)) diff --git a/remarkable_mouse/pynput.py b/remarkable_mouse/pynput.py index b9d2e76..4d63f45 100644 --- a/remarkable_mouse/pynput.py +++ b/remarkable_mouse/pynput.py @@ -1,12 +1,20 @@ import logging import struct +import threading from screeninfo import get_monitors +from pynput.mouse import Button, Controller +from queue import Empty, LifoQueue -from .common import get_monitor +from remarkable_mouse.ft5406 import TouchEvent, Touchscreen, TS_MOVE, TS_PRESS, TS_RELEASE +from datetime import datetime + +MONITORS = get_monitors() logging.basicConfig(format='%(message)s') log = logging.getLogger('remouse') +# see https://github.com/canselcik/libremarkable/blob/master/src/input/ecodes.rs + # evtype_sync = 0 # evtype_key = 1 e_type_abs = 3 @@ -17,9 +25,10 @@ e_code_stylus_xpos = 1 e_code_stylus_ypos = 0 e_code_stylus_pressure = 24 -# evcode_finger_xpos = 53 -# evcode_finger_ypos = 54 -# evcode_finger_pressure = 58 +evcode_finger_xpos = 53 +evcode_finger_ypos = 54 +evcode_finger_pressure = 58 +evcode_finger_count = 57 # wacom digitizer dimensions wacom_width = 15725 @@ -28,7 +37,6 @@ # finger_width = 767 # finger_height = 1023 - # remap wacom coordinates to screen coordinates def remap(x, y, wacom_width, wacom_height, monitor_width, monitor_height, mode, orientation): @@ -47,66 +55,183 @@ def remap(x, y, wacom_width, wacom_height, monitor_width, ratio_width, ratio_height = monitor_width / wacom_width, monitor_height / wacom_height if mode == 'fill': - scaling_x = max(ratio_width, ratio_height) - scaling_y = scaling_x + scaling = max(ratio_width, ratio_height) elif mode == 'fit': - scaling_x = min(ratio_width, ratio_height) - scaling_y = scaling_x - elif mode == 'stretch': - scaling_x = ratio_width - scaling_y = ratio_height + scaling = min(ratio_width, ratio_height) else: raise NotImplementedError return ( - scaling_x * (x - (wacom_width - monitor_width / scaling_x) / 2), - scaling_y * (y - (wacom_height - monitor_height / scaling_y) / 2) + scaling * (x - (wacom_width - monitor_width / scaling) / 2), + scaling * (y - (wacom_height - monitor_height / scaling) / 2) ) -def read_tablet(rm_inputs, *, orientation, monitor_num, region, threshold, mode): +def read_tablet(rm_inputs, *, orientation, monitor_idx, 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_num (int): monitor number to map to - region (boolean): whether to selection mapping region with region tool + monitor (int): monitor number to map to threshold (int): pressure threshold mode (str): mapping mode """ - from pynput.mouse import Button, Controller + monitor = MONITORS[monitor_idx] + log.debug('Chose monitor: {}'.format(monitor)) + q = LifoQueue() - lifted = True - new_x = new_y = False + mouse = threading.Thread(target=handle_touch, args=(rm_inputs, orientation, monitor, mode, q)) + gesture = threading.Thread(target=handle_pen, args=(rm_inputs, orientation, monitor, threshold, mode, q)) + mouse.daemon = True + gesture.daemon = True + mouse.start() + gesture.start() + mouse.join() + gesture.join() +def clean_queue(q): + while not q.empty(): + try: + q.get(False) + except Empty: + continue + q.task_done() + + +def get_or_none(q): + msg = None + try: + msg = q.get(False) + q.task_done() + except Empty: + pass + clean_queue(q) # ignore old ones and keep the queue clean + return msg + +def get_current_monitor(): + global every mouse = Controller() + for x, _monitor in enumerate(MONITORS): + if _monitor.x < mouse.position[0] < _monitor.x+_monitor.width and _monitor.y < mouse.position[1] < -_monitor.y+_monitor.height: + return _monitor + + + +def handle_touch(rm_inputs, orientation, monitor, mode, q): + mouse = Controller() + import signal + speed = 10 + + ts = Touchscreen(rm_inputs['touch'].channel, rm_inputs['touch']) + + def handle_event(event, touch, touchscreen: Touchscreen, raw_event: TouchEvent): + touchscreen.update_timestamp(event) + fingers = touchscreen.fingers + last_fingers = touchscreen.last_fingers + from_pen = get_or_none(q) # get the last one + delta_t = 10 # set high delay in case no message has been recieved + + + if from_pen: + delta_t = raw_event.timestamp-from_pen.timestamp + + if event == TS_PRESS: + touch.last_x, touch.last_y = touch.position + + if 0 < (touch.releasetime - touch.presstime) < 0.2: + mouse.press(Button.left) + mouse.release(Button.left) + + + px, py = remap( + *touch.position, + wacom_width, wacom_height, + monitor.width, monitor.height, + mode, orientation + ) + lpx, lpy = remap( + *touch.last_position, + wacom_width, wacom_height, + monitor.width, monitor.height, + mode, orientation + ) + + dx = px-lpx + dy = py-lpy + + dt = touchscreen.get_delta_time(event) + + if delta_t < 1: + return + + if fingers == 2: + mouse.scroll(dx, dy) + + if fingers == 1: + mouse.move(speed*dx, speed*dy) + + if fingers == 3 and last_fingers == 2: + mouse.press(Button.left) + mouse.move(speed*dx, speed*dy) + + if last_fingers == 3: + mouse.move(speed*dx, speed*dy) + mouse.release(Button.left) + + log.debug( + f'{["Release","Press","Move"][event]}\t'+ + f'{px}\t{lpx}\t{py}\t{lpy}\t{fingers}\t{touch.slot}\t'+ + f'{dx}\t{dy}\t{dt}\t' + f'{mouse.position}\t{get_current_monitor()}' + ) + - monitor = get_monitor(monitor_num, region, orientation) - log.debug('Chose monitor: {}'.format(monitor)) + + + for touch in ts.touches: + touch.on_press = handle_event + touch.on_release = handle_event + touch.on_move = handle_event + + ts.run() + + try: + signal.pause() + except KeyboardInterrupt: + print("Stopping thread...") + ts.stop() + exit() + + +def handle_pen(rm_inputs, orientation, monitor, threshold, mode, q): + mouse = Controller() + lifted = True + new_x = new_y = False while True: - _, _, e_type, e_code, e_value = struct.unpack('2IHHi', rm_inputs['pen'].read(16)) + tv_sec, tv_usec, e_type, e_code, e_value = struct.unpack('2IHHi', rm_inputs['pen'].read(16)) + q.put(TouchEvent(tv_sec + (tv_usec / 1000000), e_type, e_code, e_value)) - if e_type == e_type_abs: + _monitor = get_current_monitor() + if _monitor and _monitor != monitor: + monitor = _monitor + if e_type == e_type_abs: # handle x direction if e_code == e_code_stylus_xpos: - log.debug(e_value) x = e_value new_x = True # handle y direction if e_code == e_code_stylus_ypos: - log.debug('\t{}'.format(e_value)) y = e_value new_y = True # handle draw if e_code == e_code_stylus_pressure: - log.debug('\t\t{}'.format(e_value)) if e_value > threshold: if lifted: log.debug('PRESS') @@ -132,3 +257,6 @@ def read_tablet(rm_inputs, *, orientation, monitor_num, region, threshold, mode) monitor.y + mapped_y - mouse.position[1] ) new_x = new_y = False + + + diff --git a/remarkable_mouse/remarkable_mouse.py b/remarkable_mouse/remarkable_mouse.py index e7958fe..3a7546b 100755 --- a/remarkable_mouse/remarkable_mouse.py +++ b/remarkable_mouse/remarkable_mouse.py @@ -18,6 +18,7 @@ default_key = os.path.expanduser('~/.ssh/remarkable') + def open_rm_inputs(*, address, key, password): """ Open a remote input device via SSH. @@ -112,13 +113,9 @@ def main(): parser.add_argument('--key', type=str, metavar='PATH', help="ssh private key") 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', 'stretch'], help="""Scale setting. - Fit (default): take up the entire tablet, but not necessarily the entire monitor. - Fill: take up the entire monitor, but not necessarily the entire tablet. - Stretch: take up both the entire tablet and monitor, but don't maintain aspect ratio.""") - parser.add_argument('--orientation', default='right', choices=['top', 'left', 'right', 'bottom'], help="position of tablet buttons") + 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 charging port") parser.add_argument('--monitor', default=0, type=int, metavar='NUM', help="monitor to output to") - parser.add_argument('--region', action='store_true', default=False, help="Use a GUI to position the output area. Overrides --monitor") 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)") @@ -150,8 +147,7 @@ def main(): read_tablet( rm_inputs, orientation=args.orientation, - monitor_num=args.monitor, - region=args.region, + monitor_idx=args.monitor, threshold=args.threshold, mode=args.mode, ) diff --git a/remarkable_mouse/version.py b/remarkable_mouse/version.py index 98d739c..5220ac1 100644 --- a/remarkable_mouse/version.py +++ b/remarkable_mouse/version.py @@ -1 +1 @@ -__version__ = '6.0.0' +__version__ = '7.0.0'