diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..51110ea
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,23 @@
+FROM python:3.9-slim-buster
+RUN apt-get update
+RUN apt-get install -y \
+ libdbus-1-3 \
+ libfontconfig \
+ libgl1-mesa-glx \
+ libglib2.0-0 \
+ libxcb-icccm4 \
+ libxcb-image0 \
+ libxkbcommon-x11-0
+RUN apt-get clean
+WORKDIR /rmview
+COPY resources.qrc setup.cfg setup.py ./
+COPY assets ./assets
+COPY bin ./bin
+COPY src ./src
+RUN pip install --upgrade pip
+# TODO: setup.py could to be fixed to include install_requires
+# see also: https://stackoverflow.com/q/21915469/543875
+RUN pip install pyqt5==5.14.2 paramiko twisted
+RUN pip install .[tunnel]
+RUN pip cache purge
+CMD rmview
diff --git a/README.md b/README.md
index 691842e..309ed2f 100644
--- a/README.md
+++ b/README.md
@@ -8,14 +8,15 @@
* Demo [:rocket: here][demo]
* Fast streaming of the screen of your reMarkable to a window in your computer
* Support for reMarkable 1 and 2
+* Works with software version pre 2.7
+* Compatible with ScreenShare (post 2.9)
* UI for zooming, panning, rotating
* Pen tracking: a pointer follows the position of the pen when hovering on the reMarkable
* Clone a frame into separate window for reference
* Save screenshots as PNG
-> :warning: **For reMarkable 2 users** :warning::
-> rMview should work out of the box with the stock firmware.
-> If you use [`rm2fb`](https://github.com/ddvk/remarkable2-framebuffer) there are known compatibilities issues that are [being addressed](https://github.com/pl-semiotics/rM-vnc-server/issues/5).
+> :warning: **Update 2.9+ users** :warning::
+> To use rmview with the ScreenShare feature you have to **first** start the ScreenShare from the tablet, and then start rmview.
@@ -42,6 +43,9 @@ The easiest installation method is by using `pip`, from the root folder of this
(please note the command ends with a dot)
which will install all required dependencies and install a new `rmview` command.
+If you want to use the SSH tunnel feature, install with
+
+ pip install ".[tunnel]"
Then, from anywhere, you can execute `rmview` from the command line.
The tool will ask for the connection parameters and then ask permission to install the VNC server on the tablet.
@@ -55,6 +59,7 @@ Install the dependencies ([PyQt5][pyqt5], [Paramiko][paramiko], [Twisted][twiste
# install dependencies
pip install pyqt5 paramiko twisted
+ pip install sshtunnel # optional
# build resources file
pyrcc5 -o src/rmview/resources.py resources.qrc
@@ -68,6 +73,12 @@ On the reMarkable itself you need to install [rM-vnc-server][vnc] by copying the
Then you can run the program with `python -m rmview`.
+### Using Docker
+
+This project contains a `Dockerfile` so that `rmview` and all its dependencies can be installed and run inside a Docker container.
+Since `rmview` not only reads your local configuration but also needs an X11 display, you should run `docker-run.sh` which takes care of the host mappings.
+Please note that `docker-run.sh` is written for Unix-like OSes and expects your rmview configuration inside your local `$HOME/.config/rmview/` folder.
+
## Usage and configuration
**Suggested first use:**
@@ -78,9 +89,10 @@ the default configuration file which you can edit according to the documentation
More generally, you can invoke the program with
- rmview [config]
+ rmview [-v|-q] [config]
-the optional `config` parameter is the filename of a json configuration file.
+The optional `-v` flag makes the console output verbose, `-q` makes it quiet (only errors).
+The optional `config` parameter is the filename of a json configuration file.
If the parameter is not found, the program will look for a `rmview.json` file in the current directory, or, if not found, for the path stored in the environment variable `RMVIEW_CONF`.
If none are found, or if the configuration is underspecified, the tool is going to prompt for address/password.
@@ -93,6 +105,7 @@ All the settings are optional.
| Setting key | Values | Default |
| ------------------------ | ------------------------------------------------------- | ------------- |
| `ssh` | Connection parameters (see below) | `{}` |
+| `backend` | `"auto"`, `"screenshare"`, `"vncserver"` | `"auto"` |
| `orientation` | `"landscape"`, `"portrait"`, `"auto"` | `"landscape"` |
| `pen_size` | diameter of pointer in px | `15` |
| `pen_color` | color of pointer and trail | `"red"` |
@@ -100,6 +113,17 @@ All the settings are optional.
| `background_color` | color of window | `"white"` |
| `hide_pen_on_press` | if true, the pointer is hidden while writing | `true` |
| `show_pen_on_lift` | if true, the pointer is shown when lifting the pen | `true` |
+| `forward_mouse_events` | Send mouse events to tablet (see below) | `false` |
+
+**PLEASE NOTE:**
+When `backend` is `auto`, if the tablet is using version 2.9 and above then `screenshare` is used;
+otherwise `vncserver` is selected.
+Note that currently `screenshare` is only compatible with version 2.9 and above,
+and `vncserver` with version 2.6 and below.
+
+If `forward_mouse_events` is enabled, clicks and mouse drags on the main window
+will be sent to the tablet as touch events,
+mouse drags while pressing CTRL will be sent as pen events, allowing drawing.
Connection parameters are provided as a dictionary with the following keys (all optional):
@@ -113,6 +137,8 @@ Connection parameters are provided as a dictionary with the following keys (all
| `key` | Local path to key for ssh | not needed if password provided |
| `timeout` | Connection timeout in seconds | default: 1 |
| `host_key_policy` | `"ask"`, `"ignore_new"`, `"ignore_all"`, `"auto_add"` | default: `"ask"` (description below) |
+| `tunnel` | True to connect to VNC server over a local SSH tunnel | default: `false` (description below) |
+| `tunnel_compression` | True to enable compression for SSH tunnel | default: `false` (description below) |
The `address` parameter can be either:
- a single string, in which case the address is used for connection
@@ -121,6 +147,7 @@ The `address` parameter can be either:
To establish a connection with the tablet, you can use any of the following:
- Leave `auth_method`, `password` and `key` unspecified: this will ask for a password
- Specify `"auth_method": "key"` to use a SSH key. In case an SSH key hasn't already been associated with the tablet, you can provide its path with the `key` setting.
+ If key is password protected, you can specify key passphrase using `password` parameter.
- Provide a `password` in settings
If `auth_method` is `password` but no password is specified, then the tool will ask for the password on connection.
@@ -141,19 +168,45 @@ The old `"insecure_auto_add_host": true` parameter is deprecated and equivalent
In case your `~/.ssh/known_hosts` file contains the relevant key associations, rMview should pick them up.
If you use the "Add/Update" feature when prompted by rMview (for example after a tablet update) then `~/.ssh/known_hosts` will be ignored from then on.
-
:warning: **Key format error:**
If you get an error when connect using a key, but the key seems ok when connecting manually with ssh, you probably need to convert the key to the PEM format (or re-generate it using the `-m PEM` option of `ssh-keygen`). See [here](https://github.com/paramiko/paramiko/issues/340#issuecomment-492448662) for details.
+NOTE: If you have a lot of known hosts in system known hosts file (`~/.ssh/known_hosts`), you are advised to add
+known host entry for remarkable to `~/.config/rmview_known_hosts` because paramiko can be very slow when loading
+large known hosts file which slows down the whole connection routine.
+
+If your user system known hosts file already contains entry for remarkable, you can add it to rmview specific
+hosts file using this command:
+
+```bash
+cat ~/.ssh/known_hosts | grep 10.11.99.1 >> ~/.config/rmview_known_hosts
+```
+
+You should of course replace IP with your remarkable IP.
+
+### Note on security and using an SSH tunnel
+
+By default, this program will start VNC server on remarkable which listens on all the interfaces and doesn't expose
+any authentication mechanism or uses encryption.
+
+This program will then connect to the VNC server over the IP specified in the config.
+
+Not using any authentication and exposing VNC server on all the network interfaces may be OK when connecting to the
+remarkable over USB interface, but when you are connecting to remarkable over WLAN, you are strongly encouraged to
+use built-in SSH tunnel functionality.
+
+When SSH tunnel functionality is used, VNC server which is started on remarkable will only listen on localhost, this
+program will create SSH tunnel to the remarkable and connect to the VNC server over the local SSH tunnel.
+
+This means that the connection will be encrypted and existing SSH authentication will be used.
## To Do
- [ ] Settings dialog
- [ ] About dialog
- - [ ] Pause stream of screen/pen
+ - [x] Pause stream of screen/pen
- [ ] Binary bundles for Window, Linux and MacOs (PyInstaller?)
- [ ] Add interaction for Lamy button? (1 331 1 down, 1 331 0 up)
- - [ ] Remove dependency to Twisted in `vnc` branch
## Legacy reStreamer-like version
diff --git a/assets/connecting.png b/assets/connecting.png
new file mode 100644
index 0000000..ae440a6
Binary files /dev/null and b/assets/connecting.png differ
diff --git a/docker-run.sh b/docker-run.sh
new file mode 100755
index 0000000..1f5fb69
--- /dev/null
+++ b/docker-run.sh
@@ -0,0 +1,13 @@
+#!/bin/bash
+set -e
+
+CONFIG_DIR=$HOME/.config/rmview
+mkdir -p $CONFIG_DIR
+xhost local:root
+docker build -t rmview .
+docker run \
+ --env DISPLAY=$DISPLAY \
+ --network host \
+ --volume $CONFIG_DIR:/root/.config \
+ --volume /tmp/.X11-unix:/tmp/.X11-unix \
+ rmview
diff --git a/example_ssh_key_auth_with_ssh_tunnel.json b/example_ssh_key_auth_with_ssh_tunnel.json
new file mode 100644
index 0000000..cda71fa
--- /dev/null
+++ b/example_ssh_key_auth_with_ssh_tunnel.json
@@ -0,0 +1,17 @@
+{
+ "ssh": {
+ "timeout": 4,
+ "address": "192.168.160.100",
+ "username": "root",
+ "auth_method": "key",
+ "key": "/home/user/.ssh/id_rsa_remarkable",
+ "password": "ssh key passphrase",
+ "tunnel": true
+ },
+ "orientation": "auto",
+ "pen_size": 15,
+ "pen_color": "red",
+ "pen_trail": 200,
+ "background_color": "white",
+ "hide_pen_on_press": true
+}
diff --git a/resources.qrc b/resources.qrc
index c88190d..801adef 100644
--- a/resources.qrc
+++ b/resources.qrc
@@ -4,7 +4,8 @@
assets/tablet.svg
assets/dead.svg
assets/problem.svg
+ assets/connecting.png
bin/rM1-vnc-server-standalone
bin/rM2-vnc-server-standalone
-
\ No newline at end of file
+
diff --git a/setup.cfg b/setup.cfg
index 76abe14..8440f46 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,7 +1,10 @@
+[pycodestyle]
+max-line-length = 88
+
[options]
package_dir=
=src
packages=find:
[options.packages.find]
-where=src
\ No newline at end of file
+where=src
diff --git a/setup.py b/setup.py
index 86c4587..ca1b90f 100644
--- a/setup.py
+++ b/setup.py
@@ -32,7 +32,7 @@ def run(self):
setup(
name='rmview',
- version='2.1',
+ version='2.2',
url='https://github.com/bordaigorl/rmview',
description='rMview: a fast live viewer for reMarkable',
author='bordaigorl',
@@ -42,6 +42,7 @@ def run(self):
],
packages=['rmview'],
install_requires=['pyqt5', 'paramiko', 'twisted'],
+ extras_require = { 'tunnel': ['sshtunnel'] },
entry_points={
'console_scripts':['rmview = rmview.rmview:rmViewMain']
},
diff --git a/src/rmview/connection.py b/src/rmview/connection.py
index 3f00e6c..5b7eb47 100644
--- a/src/rmview/connection.py
+++ b/src/rmview/connection.py
@@ -6,6 +6,7 @@
import paramiko
import struct
import time
+import re
from binascii import hexlify
import sys
@@ -60,60 +61,103 @@ class rMConnectSignals(QObject):
class rMConnect(QRunnable):
_exception = None
+ _known_hosts = None
- def __init__(self, address='10.11.99.1', username='root', password=None, key=None, timeout=1,
+ def __init__(self, address='10.11.99.1', username='root', password=None, key=None, timeout=3,
onConnect=None, onError=None, host_key_policy=None, known_hosts=None, **kwargs):
super(rMConnect, self).__init__()
+
self.address = address
+ self.username = username
+ self.password = password
+ self.timeout = timeout
+ self.host_key_policy = host_key_policy
+ self._known_hosts = known_hosts
+
+ if key is not None:
+ key = os.path.expanduser(key)
+
+ if password:
+ # password protected key file, password provided in the config
+ self.pkey = paramiko.RSAKey.from_private_key_file(key, password=password)
+ else:
+ try:
+ self.pkey = paramiko.RSAKey.from_private_key_file(key)
+ except paramiko.ssh_exception.PasswordRequiredException:
+ passphrase, ok = QInputDialog.getText(None, "Configuration","SSH key passphrase:",
+ QLineEdit.Password)
+ if ok:
+ self.pkey = paramiko.RSAKey.from_private_key_file(key, password=passphrase)
+ else:
+ raise Exception("A passphrase for SSH key is required")
+ else:
+ self.pkey = None
+ if self.password is None:
+ log.warning("No key nor password given. System-wide SSH connection parameters are going to be used.")
+
+
self.signals = rMConnectSignals()
+
+ self.client = None
+ self._exception = None
+
if callable(onConnect):
self.signals.onConnect.connect(onConnect)
if callable(onError):
self.signals.onError.connect(onError)
+ def _initialize(self):
+ # NOTE: Loading system known hosts can take a long time that's why it should happen inside
+ # run() so it doesn't block the main qt render loop which will cause main QT window to freeze
+ # until the loading completes.
try:
self.client = paramiko.SSHClient()
- if host_key_policy != "ignore_all":
- if known_hosts and os.path.isfile(known_hosts):
- self.client.load_host_keys(known_hosts)
- log.info("LOADED %s", known_hosts)
+ if self.host_key_policy != "ignore_all":
+ if self._known_hosts and os.path.isfile(self._known_hosts):
+ log.info("Using known hosts file: %s" % (self._known_hosts))
+ self.client.load_host_keys(self._known_hosts)
+ log.info("Loaded known hosts from %s", self._known_hosts)
else:
+ log.info("Using system default known hosts file")
+ log.info("Loading system default known hosts file, this may take a while...")
# ideally we would want to always load the system ones
# and have the local keys have precedence, but paramiko gives
# always precedence to system keys
+ # There is extremly slow in system with many known host entries... :/
+ # See https://github.com/paramiko/paramiko/issues/191
self.client.load_system_host_keys()
+ log.info("System default known host file loaded")
-
- policy = HOST_KEY_POLICY.get(host_key_policy, RejectNewHostKey)
+ policy = HOST_KEY_POLICY.get(self.host_key_policy, RejectNewHostKey)
self.client.set_missing_host_key_policy(policy())
- if key is not None:
- key = os.path.expanduser(key)
- try:
- pkey = paramiko.RSAKey.from_private_key_file(key)
- except paramiko.ssh_exception.PasswordRequiredException:
- passphrase, ok = QInputDialog.getText(None, "Configuration","SSH key passphrase:", QLineEdit.Password)
- if ok:
- pkey = paramiko.RSAKey.from_private_key_file(key, password=passphrase)
- else:
- raise Exception("A passphrase for SSH key is required")
- else:
- pkey = None
- if password is None:
- log.warning("No key nor password given. System-wide SSH connection parameters are going to be used.")
-
self.options = {
- 'username': username,
- 'password': password,
- 'pkey': pkey,
- 'timeout': timeout,
+ 'username': self.username,
+ 'password': self.password,
+ 'pkey': self.pkey,
+ 'timeout': self.timeout,
}
except Exception as e:
self._exception = e
+ def _getVersion(self):
+ _, out, _ = self.client.exec_command("cat /sys/devices/soc0/machine")
+ rmv = out.read().decode("utf-8")
+ version = re.fullmatch(r"reMarkable(?: Prototype)? (\d+)(\.\d+)*\n", rmv)
+ if version is not None:
+ version = int(version[1])
+ return version, rmv.strip()
+
+ def _getSwVersion(self):
+ _, out, _ = self.client.exec_command("cat /etc/version")
+ return int(out.read().decode("utf-8"))
+
+
@pyqtSlot()
def run(self):
+ self._initialize()
+
if self._exception is not None:
self.signals.onError.emit(self._exception)
log.debug('Aborting connection: %s', self._exception)
@@ -123,11 +167,18 @@ def run(self):
self.client.connect(self.address, **self.options)
log.info("Connected to {}".format(self.address))
self.client.hostname = self.address
+ self.client.deviceVersion, self.client.fullDeviceVersion = self._getVersion()
+ self.client.softwareVersion = self._getSwVersion()
self.signals.onConnect.emit(self.client)
except Exception as e:
log.error("Could not connect to %s: %s", self.address, e)
log.info("Please check your remarkable is connected and retry.")
self.signals.onError.emit(e)
+ try:
+ if self._known_hosts and os.path.isfile(self._known_hosts):
+ self.client.save_host_keys(self._known_hosts)
+ except Exception as e:
+ log.warning("Could not save known keys at '%s'" % self._known_hosts)
log.debug('Stopping connection worker')
diff --git a/src/rmview/pentracker.py b/src/rmview/pentracker.py
new file mode 100644
index 0000000..f6821ee
--- /dev/null
+++ b/src/rmview/pentracker.py
@@ -0,0 +1,104 @@
+from PyQt5.QtGui import *
+from PyQt5.QtWidgets import *
+from PyQt5.QtCore import *
+
+from .rmparams import *
+
+import paramiko
+import struct
+import time
+
+import sys
+import os
+import logging
+
+log = logging.getLogger('rmview')
+
+
+class PenTrackerSignals(QObject):
+ onFatalError = pyqtSignal(Exception)
+ onPenMove = pyqtSignal(int, int)
+ onPenPress = pyqtSignal()
+ onPenLift = pyqtSignal()
+ onPenNear = pyqtSignal()
+ onPenFar = pyqtSignal()
+
+
+LIFTED = 0
+PRESSED = 1
+
+
+class PenTracker(QRunnable):
+
+ _stop = False
+
+ def __init__(self, ssh, path="/dev/input/event0", threshold=1000):
+ super(PenTracker, self).__init__()
+ self.event = path
+ self.ssh = ssh
+ self.threshold = threshold
+ self.signals = PenTrackerSignals()
+
+ @pyqtSlot()
+ def pause(self):
+ self.signals.blockSignals(True)
+
+ @pyqtSlot()
+ def resume(self):
+ self.signals.blockSignals(False)
+
+ def stop(self):
+ self._penkill.write('\n')
+ self._stop = True
+
+ @pyqtSlot()
+ def run(self):
+ penkill, penstream, _ = self.ssh.exec_command('cat %s & { read ; kill %%1; }' % self.event)
+ self._penkill = penkill
+ new_x = new_y = False
+ state = LIFTED
+
+ while not self._stop:
+ try:
+ _, _, e_type, e_code, e_value = struct.unpack('2IHHi', penstream.read(16))
+ except struct.error:
+ return
+ except Exception as e:
+ log.error('Error in pointer worker: %s %s', type(e), e)
+ return
+
+ # decoding adapted from remarkable_mouse
+ if e_type == e_type_abs:
+
+ # handle x direction
+ if e_code == e_code_stylus_xpos:
+ x = e_value
+ new_x = True
+
+ # handle y direction
+ if e_code == e_code_stylus_ypos:
+ y = e_value
+ new_y = True
+
+ # handle draw
+ if e_code == e_code_stylus_pressure:
+ if e_value > self.threshold:
+ if state == LIFTED:
+ log.debug('PRESS')
+ state = PRESSED
+ self.signals.onPenPress.emit()
+ else:
+ if state == PRESSED:
+ log.debug('RELEASE')
+ state = LIFTED
+ self.signals.onPenLift.emit()
+
+ if new_x and new_y:
+ self.signals.onPenMove.emit(x, y)
+ new_x = new_y = False
+
+ if e_type == e_type_key and e_code == e_code_stylus_proximity:
+ if e_value == 0:
+ self.signals.onPenFar.emit()
+ else:
+ self.signals.onPenNear.emit()
diff --git a/src/rmview/rfb.py b/src/rmview/rfb.py
index bbf0d26..ca16b38 100644
--- a/src/rmview/rfb.py
+++ b/src/rmview/rfb.py
@@ -174,7 +174,7 @@ def __init__(self):
def _handleInitial(self):
buffer = b''.join(self._packet)
if b'\n' in buffer:
- version = 3.3
+ version = 3.8
if buffer[:3] == b'RFB':
version_server = float(buffer[3:-1].replace(b'0', b''))
SUPPORTED_VERSIONS = (3.3, 3.7, 3.8)
@@ -195,6 +195,7 @@ def _handleInitial(self):
self._handler = self._handleExpected
self._version = version
self._version_server = version_server
+
if version < 3.7:
self.expect(self._handleAuth, 4)
else:
@@ -212,7 +213,7 @@ def _handleNumberSecurityTypes(self, block):
def _handleSecurityTypes(self, block):
types = unpack("!%dB" % len(block), block)
- SUPPORTED_TYPES = (1, 2)
+ SUPPORTED_TYPES = (1, 2, 100)
valid_types = [sec_type for sec_type in types if sec_type in SUPPORTED_TYPES]
if valid_types:
sec_type = max(valid_types)
@@ -222,11 +223,29 @@ def _handleSecurityTypes(self, block):
self._doClientInitialization()
else:
self.expect(self._handleVNCAuthResult, 4)
+ elif sec_type == 100:
+ self.expect(self._handleRMAuth,4)
else:
self.expect(self._handleVNCAuth, 16)
else:
log.msg("unknown security types: %s" % repr(types))
+ def _handleRMAuth(self, block):
+ #4 zero bytes ignored
+
+ #TODO: the security is not checked atm, so an empty challenged is sent
+ #the algo for the challenge is a sha256(timestamp+sha256(usedId))
+ #the timestamp comes from the udp broadcast on port 5901
+ self.transport.write(pack("!I", 32)) #challenge length
+ self.transport.write(b'\x00'*32) #challenge
+ self.expect(self._handleRMResult, 1)
+
+ def _handleRMResult(self, block):
+ if block[0] != 0:
+ log.msg("auth failed, currently ignored")
+ self._doClientInitialization()
+
+
def _handleAuth(self, block):
(auth,) = unpack("!I", block)
#~ print "auth:", auth
@@ -818,22 +837,6 @@ class RFBFactory(protocol.ClientFactory):
# should be overriden by application to use a derrived class
protocol = RFBClient
- def __init__(self, password = None, shared = 0):
+ def __init__(self, password = None, shared = 1):
self.password = password
self.shared = shared
-
-# class RFBDes(pyDes.des):
-# def setKey(self, key):
-# """RFB protocol for authentication requires client to encrypt
-# challenge sent by server with password using DES method. However,
-# bits in each byte of the password are put in reverse order before
-# using it as encryption key."""
-# newkey = []
-# for ki in range(len(key)):
-# bsrc = ord(key[ki])
-# btgt = 0
-# for i in range(8):
-# if bsrc & (1 << i):
-# btgt = btgt | (1 << 7-i)
-# newkey.append(chr(btgt))
-# super(RFBDes, self).setKey(newkey)
diff --git a/src/rmview/rmparams.py b/src/rmview/rmparams.py
index 4e81ae5..a14e8cb 100644
--- a/src/rmview/rmparams.py
+++ b/src/rmview/rmparams.py
@@ -3,6 +3,12 @@
PIXELS_NUM = WIDTH * HEIGHT
TOTAL_BYTES = PIXELS_NUM * 2
+SW_VER_TIMESTAMPS = {
+ '2.7': 20210504114631,
+ '2.9': 20210709092503
+}
+
+
# evtype_sync = 0
e_type_key = 1
e_type_abs = 3
@@ -19,4 +25,59 @@
e_code_stylus_proximity = 320
stylus_width = 15725
-stylus_height = 20951
\ No newline at end of file
+stylus_height = 20951
+
+
+# Heuristic detection of orientation
+# based on locating the menu button (O) and close button (X)
+
+CIRCLE_BLACK = [
+ (-18,0), (-13,-13), (0,-18), (13,-13), (18,0), (13,13), (0,18), (-13,13)
+]
+CIRCLE_WHITE = [
+ (-14,0), (-10,-10), (0,-14), (10,-10), (14,0), (10,10), (0,14), (-10,10)
+]
+CIRCLE_ICON = [(-5,-5), (-5,5), (5,-5), (5,5)]
+
+CIRCLE_POS = [(59,60), (60,1812), (1343,60)]
+
+BLACK = 4278190080
+WHITE = 4294967295
+
+O_BUTTON = 1
+X_BUTTON = 2
+
+def find_circle_buttons(img):
+ return [find_circle_button(img, x, y) for (x,y) in CIRCLE_POS]
+
+def find_circle_button(img, x, y):
+ p = img.pixel
+ for (dx,dy) in CIRCLE_BLACK:
+ if p(x+dx,y+dy) != BLACK:
+ return None
+ for (dx,dy) in CIRCLE_WHITE:
+ if p(x+dx,y+dy) != WHITE:
+ return None
+ b = [p(x+dx,y+dy) == BLACK for (dx,dy) in CIRCLE_ICON]
+ if all(b):
+ return X_BUTTON
+ if sum(b) == 1:
+ return O_BUTTON
+ else:
+ return None
+
+
+# NAMES = {
+# BLACK: 'b',
+# WHITE: 'w'
+# }
+
+# def debug_circle_buttons(img):
+# return [debug_circle_button(img, x, y) for (x,y) in CIRCLE_POS]
+
+# def debug_circle_button(img, x, y):
+# p = img.pixel
+# b = [ NAMES.get(p(x+dx,y+dy), 'x') for (dx,dy) in CIRCLE_BLACK ]
+# w = [ NAMES.get(p(x+dx,y+dy), 'x') for (dx,dy) in CIRCLE_WHITE ]
+# i = [ NAMES.get(p(x+dx,y+dy), 'x') for (dx,dy) in CIRCLE_ICON ]
+# return (b,w,i)
diff --git a/src/rmview/rmview.py b/src/rmview/rmview.py
index 6713ea9..6c22b6e 100644
--- a/src/rmview/rmview.py
+++ b/src/rmview/rmview.py
@@ -3,7 +3,10 @@
from PyQt5.QtCore import *
from . import resources
-from .workers import FrameBufferWorker, PointerWorker
+from .screenstream.common import KEY_Left, KEY_Right, KEY_Escape
+from .screenstream.vnc import VncStreamer
+from .screenstream.screenshare import ScreenShareStream
+from .pentracker import PenTracker
from .connection import rMConnect, RejectNewHostKey, AddNewHostKey, UnknownHostKeyException
from .viewer import QtImageViewer
@@ -13,11 +16,14 @@
import sys
import os
+import stat
import json
import re
+import signal
+import time
import logging
-logging.basicConfig(format='%(message)s')
+logging.basicConfig(format='[%(levelname)s] %(message)s')
log = logging.getLogger('rmview')
@@ -31,10 +37,15 @@ class rMViewApp(QApplication):
penworker = None
ssh = None
+ streaming = True
+ right_mode = True
+
pen = None
pen_size = 15
trail = None # None: disabled, False: inactive, True: active
+ cloned_frames = set()
+
def __init__(self, args):
super(rMViewApp, self).__init__(args)
@@ -61,6 +72,9 @@ def __init__(self, args):
log.error("Malformed configuration in %s: %s" % (f, e))
except Exception as e:
log.debug("Configuration failure in %s: %s" % (f, e))
+
+ self._checkConfigFilePermissions(self.config_file)
+
self.config.setdefault('ssh', {})
self.pen_size = self.config.get('pen_size', self.pen_size)
self.trailPen = QPen(QColor(self.config.get('pen_color', 'red')), max(1, self.pen_size // 3))
@@ -68,61 +82,92 @@ def __init__(self, args):
self.trailPen.setJoinStyle(Qt.RoundJoin)
self.trailDelay = self.config.get('pen_trail', 200)
self.trail = None if self.trailDelay == 0 else False
+ self.right_mode = self.config.get('right_mode', True)
self.bar = QMenuBar()
self.setWindowIcon(QIcon(':/assets/rmview.svg'))
self.viewer = QtImageViewer()
+
if 'background_color' in self.config:
self.viewer.setBackgroundBrush(QBrush(QColor(self.config.get('background_color'))))
- act = QAction('Clone current frame', self)
- act.triggered.connect(self.cloneViewer)
- self.viewer.menu.addAction(act)
+ ### ACTIONS
+ self.cloneAction = QAction('Clone current frame', self.viewer)
+ self.cloneAction.setShortcut(QKeySequence.New)
+ self.cloneAction.triggered.connect(self.cloneViewer)
+ self.viewer.addAction(self.cloneAction)
###
- self.viewer.menu.addSeparator() # --------------------------
+ self.pauseAction = QAction('Pause Streaming', self.viewer)
+ self.pauseAction.setShortcut('Ctrl+P')
+ self.pauseAction.triggered.connect(self.toggleStreaming)
+ self.viewer.addAction(self.pauseAction)
###
- act = QAction('Settings...', self)
- act.triggered.connect(self.openSettings)
- self.viewer.menu.addAction(act)
+ self.settingsAction = QAction('Settings...', self.viewer)
+ self.settingsAction.triggered.connect(self.openSettings)
+ self.viewer.addAction(self.settingsAction)
###
- self.viewer.menu.addSeparator() # --------------------------
+ self.quitAction = QAction('Quit', self.viewer)
+ self.quitAction.setShortcut('Ctrl+Q')
+ self.quitAction.triggered.connect(self.quit)
+ self.viewer.addAction(self.quitAction)
+ ###
+ self.leftAction = QAction('Emulate Left Button', self)
+ self.leftAction.setShortcut('Ctrl+Left')
+ self.leftAction.triggered.connect(lambda: self.fbworker.keyEvent(KEY_Left))
+ self.viewer.addAction(self.leftAction)
###
- act = QAction('Quit', self)
- act.setShortcut('Ctrl+Q')
- act.triggered.connect(self.quit)
- self.viewer.menu.addAction(act)
+ self.rightAction = QAction('Emulate Right Button', self)
+ self.rightAction.setShortcut('Ctrl+Right')
+ self.rightAction.triggered.connect(lambda: self.fbworker.keyEvent(KEY_Right))
+ self.viewer.addAction(self.rightAction)
+ ###
+ self.homeAction = QAction('Emulate Central Button', self)
+ self.homeAction.setShortcut(QKeySequence.Cancel)
+ self.homeAction.triggered.connect(lambda: self.fbworker.keyEvent(KEY_Escape))
+ self.viewer.addAction(self.homeAction)
+
+
+ ### VIEWER MENU ADDITIONS
+ self.viewer.menu.addAction(self.cloneAction)
+ self.viewer.menu.addAction(self.pauseAction)
+ # inputMenu = self.viewer.menu.addMenu("Input")
+ # inputMenu.addAction(self.leftAction)
+ # inputMenu.addAction(self.rightAction)
+ # inputMenu.addAction(self.homeAction)
+ self.viewer.menu.addSeparator() # --------------------------
+ self.viewer.menu.addAction(self.settingsAction)
+ self.viewer.menu.addSeparator() # --------------------------
+ self.viewer.menu.addAction(self.quitAction)
self.viewer.setWindowTitle("rMview")
self.viewer.show()
- self.orient = None
+ # Display connecting image until we successfuly connect
+ self.viewer.setImage(QPixmap(':/assets/connecting.png'))
+
+ self.orient = 0
orient = self.config.get('orientation', 'landscape')
if orient == 'landscape':
self.viewer.rotateCW()
self.autoResize(WIDTH / HEIGHT)
elif orient == 'portrait':
self.autoResize(HEIGHT / WIDTH)
- else: # orient
+ else: # auto
self.autoResize(HEIGHT / WIDTH)
- self.orient = True
-
- # Setup global menu
- menu = self.bar.addMenu('&View')
- act = QAction('Rotate clockwise', self)
- act.setShortcut('Ctrl+Right')
- act.triggered.connect(self.viewer.rotateCW)
- menu.addAction(act)
- act = QAction('Rotate counter-clockwise', self)
- act.setShortcut('Ctrl+Left')
- act.triggered.connect(self.viewer.rotateCCW)
- menu.addAction(act)
- menu.addSeparator()
- act = QAction('Save screenshot', self)
- act.setShortcut('Ctrl+S')
- act.triggered.connect(self.viewer.screenshot)
- menu.addAction(act)
- menu.addSeparator()
+ self.orient = 1 if orient == "auto_on_load" else 2
+
+ # # Setup global menu
+ # menu = self.bar.addMenu('&View')
+ # menu.addAction(self.viewer.rotCWAction)
+ # menu.addAction(self.viewer.rotCCWAction)
+ # menu.addSeparator()
+ # menu.addAction(self.viewer.screenshotAction)
+ # menu.addSeparator()
+ # menu.addAction(self.pauseAction)
+ # menu.addAction(self.leftAction)
+ # menu.addAction(self.rightAction)
+ # menu.addAction(self.homeAction)
if not self.ensureConnConfig(): # I know, it's ugly
@@ -134,28 +179,25 @@ def __init__(self, args):
self.requestConnect()
def detectOrientation(self, image):
- c = image.pixel
- portrait = False
- # print(c(48, 47) , c(72, 72) , c(55, 55) , c(64, 65))
- if c(48, 47) == 4278190080 and c(72, 72) == 4278190080 and \
- (c(55, 55) == 4294967295 or c(64, 65) == 4294967295):
- if c(61, 1812) != 4278190080 or c(5,5) == 4278190080:
- portrait = True
- elif c(1356, 47) == 4278190080 and c(1329, 72) == 4278190080 and \
- (c(1348, 54) == 4294967295 or c(1336, 65) == 4294967295):
- portrait = True
- elif c(5,5) == 4278190080:
- portrait = True
- elif c(40,47) == 4278190080 and c(40,119) == 4278190080:
- portrait = True
- if portrait:
- self.viewer.portrait()
- self.autoResize(HEIGHT / WIDTH)
+ (tl,bl,tr) = find_circle_buttons(image)
+ if tl is None and bl is None and tr is None:
+ portrait = True # We are in the main screen/settings
+ elif bl is None:
+ portrait = self.right_mode
else:
- self.viewer.landscape()
- self.autoResize(WIDTH / HEIGHT)
+ portrait = False
+
+ if portrait:
+ if not self.viewer.is_portrait():
+ self.viewer.portrait()
+ self.autoResize(HEIGHT / WIDTH)
+ elif not self.viewer.is_landscape():
+ self.viewer.landscape()
+ self.autoResize(WIDTH / HEIGHT)
def autoResize(self, ratio):
+ if self.viewer.windowState() & (QWindow.FullScreen | QWindow.Maximized):
+ return
dg = self.desktop().availableGeometry(self.viewer)
ds = dg.size() * 0.7
if ds.width() * ratio > ds.height():
@@ -194,9 +236,32 @@ def ensureConnConfig(self):
if not os.path.isfile(self.LOCAL_KNOWN_HOSTS):
open(self.LOCAL_KNOWN_HOSTS, 'a').close()
- log.info(self.config)
+ if log.isEnabledFor(logging.DEBUG):
+ import copy
+ config_sanitized = copy.deepcopy(self.config)
+ if "password" in self.config.get("ssh", {}):
+ config_sanitized["ssh"]["password"] = config_sanitized["ssh"]["password"][:3] + "*****"
+ log.debug("Config values: %s" % (str(config_sanitized)))
+
return True
+ def _checkConfigFilePermissions(self, file_path):
+ """
+ Emit a warning message if config file is readable by others.
+ """
+ st_mode = os.stat(file_path).st_mode
+
+ if bool(st_mode & stat.S_IROTH) or bool(st_mode & stat.S_IWOTH):
+ file_permissions = str(oct(st_mode)[4:])
+
+ if file_permissions.startswith("0") and len(file_permissions) == 4:
+ file_permissions = file_permissions[1:]
+
+ log.warn("Config file \"%s\" is readable by others (permissions=%s). If your config "
+ "file contains secrets (e.g. password) you are strongly encouraged to make sure "
+ "it's not readable by other users (chmod 600 %s)" % (file_path, file_permissions,
+ file_path))
+
def requestConnect(self, host_key_policy=None):
self.viewer.setWindowTitle("rMview - Connecting...")
args = self.config.get('ssh')
@@ -222,75 +287,55 @@ def joinWorkers(self):
@pyqtSlot(object)
def connected(self, ssh):
self.ssh = ssh
- self.viewer.setWindowTitle("rMview - " + self.config.get('ssh').get('address'))
-
- _,out,_ = ssh.exec_command("cat /sys/devices/soc0/machine")
- rmv = out.read().decode("utf-8")
- version = re.fullmatch(r"reMarkable(?: Prototype)? (\d+)(\.\d+)*\n", rmv)
- if version is None or version[1] not in ["1", "2"]:
- log.error("Device is unsupported: '%s' [%s]", rmv.strip(), version[1] if version else "unknown device")
- QMessageBox.critical(None, "Unsupported device", "The detected device is '%s'.\nrmView currently only supports reMarkable 1 and 2." % rmv.strip())
+ self.viewer.setWindowTitle("rMview - " + ssh.hostname)
+
+ log.info("Detected %s", ssh.fullDeviceVersion)
+ version = ssh.deviceVersion
+ if version not in [1, 2]:
+ log.error("Device is unsupported: '%s' [%s]", ssh.fullDeviceVersion, version or "unknown device")
+ QMessageBox.critical(None, "Unsupported device", "The detected device is '%s'.\nrmView currently only supports reMarkable 1 and 2." % ssh.fullDeviceVersion)
self.quit()
return
- version = int(version[1])
-
- # check needed files are in place
- _,out,_ = ssh.exec_command("[ -x $HOME/rM-vnc-server-standalone ]")
- if out.channel.recv_exit_status() != 0:
- mbox = QMessageBox(QMessageBox.NoIcon, 'Missing components', 'Your reMarkable is missing some needed components.')
- icon = QPixmap(":/assets/problem.svg")
- icon.setDevicePixelRatio(self.devicePixelRatio())
- mbox.setIconPixmap(icon)
- mbox.setInformativeText(
- "To work properly, rmView needs the rM-vnc-server-standalone program "\
- "to be installed on your tablet.\n"\
- "You can install them manually, or let rmView do the work for you by pressing 'Auto Install' below.\n\n"\
- "If you are unsure, please consult the documentation.")
- mbox.addButton(QMessageBox.Cancel)
- mbox.addButton(QMessageBox.Help)
- mbox.addButton("Settings...", QMessageBox.ResetRole)
- mbox.addButton("Auto Install", QMessageBox.AcceptRole)
- mbox.setDefaultButton(0)
- answer = mbox.exec()
- log.info(answer)
- if answer == 1:
- log.info("Installing...")
- try:
- sftp = ssh.open_sftp()
- from stat import S_IXUSR
- fo = QFile(':bin/rM%d-vnc-server-standalone' % version)
- fo.open(QIODevice.ReadOnly)
- sftp.putfo(fo, 'rM-vnc-server-standalone')
- fo.close()
- sftp.chmod('rM-vnc-server-standalone', S_IXUSR)
- log.info("Installation successful!")
- except Exception as e:
- log.error('%s %s', type(e), e)
- QMessageBox.critical(None, "Error", 'There has been an error while trying to install the required components on the tablet.\n%s\n.' % e)
- self.quit()
- return
- elif answer == QMessageBox.Cancel:
- self.quit()
- return
- elif answer == QMessageBox.Help:
- QDesktopServices.openUrl(QUrl("https://github.com/bordaigorl/rmview"))
- self.quit()
- return
+ backend = self.config.get('backend', 'auto')
+ if backend == 'auto':
+ if ssh.softwareVersion >= SW_VER_TIMESTAMPS['2.9']:
+ backend = 'screenshare'
else:
- self.openSettings(prompt=False)
- return
+ backend = 'vncserver'
+ if ssh.softwareVersion >= SW_VER_TIMESTAMPS['2.7']:
+ log.warning("Detected version 2.7 or 2.8. The server might not work with these versions.")
+
+ log.info("Using backend '%s'", backend)
+ if backend == 'screenshare':
+ self.fbworker = ScreenShareStream(ssh)
+ # does not support key/pointer events
+ self.leftAction.setEnabled(False)
+ self.rightAction.setEnabled(False)
+ self.homeAction.setEnabled(False)
+ elif backend == 'vncserver':
+ self.fbworker = VncStreamer(ssh, ssh_config=self.config.get('ssh', {}),
+ delay=self.config.get('fetch_frame_delay'))
- self.fbworker = FrameBufferWorker(ssh, delay=self.config.get('fetch_frame_delay'))
self.fbworker.signals.onNewFrame.connect(self.onNewFrame)
self.fbworker.signals.onFatalError.connect(self.frameError)
+
+ # check needed files are in place
+ if self.fbworker.needsDependencies():
+ if not self.promptDependenciesInstall():
+ return
+
self.threadpool.start(self.fbworker)
+ if self.config.get("forward_mouse_events", False):
+ self.viewer.pointerEvent.connect(self.fbworker.pointerEvent)
- self.penworker = PointerWorker(ssh, path="/dev/input/event%d" % (version-1))
+ self.penworker = PenTracker(ssh, path="/dev/input/event%d" % (version-1))
self.threadpool.start(self.penworker)
self.pen = self.viewer.scene.addEllipse(0,0,self.pen_size,self.pen_size,
pen=QPen(QColor('white')),
brush=QBrush(QColor(self.config.get('pen_color', 'red'))))
+ self.pen.lastShown = None
+ self.pen.showDelay = self.config.get("pen_show_delay", 0.4)
self.pen.hide()
self.pen.setZValue(100)
self.penworker.signals.onPenMove.connect(self.movePen)
@@ -298,27 +343,74 @@ def connected(self, ssh):
self.penworker.signals.onPenLift.connect(self.showPen)
if self.config.get("hide_pen_on_press", True):
self.penworker.signals.onPenPress.connect(self.hidePen)
- self.penworker.signals.onPenNear.connect(self.showPen)
+ self.penworker.signals.onPenNear.connect(self.showPenNow)
self.penworker.signals.onPenFar.connect(self.hidePen)
+ def promptDependenciesInstall(self):
+ mbox = QMessageBox(QMessageBox.NoIcon, 'Missing components', 'Your reMarkable is missing some needed components.')
+ icon = QPixmap(":/assets/problem.svg")
+ icon.setDevicePixelRatio(self.devicePixelRatio())
+ mbox.setIconPixmap(icon)
+ mbox.setInformativeText(
+ "To work properly, rmView needs some dependencies "\
+ "to be installed on your tablet.\n"\
+ "You can install them manually, or let rmView do the work for you by pressing 'Auto Install' below.\n\n"\
+ "If you are unsure, please consult the documentation.")
+ mbox.addButton(QMessageBox.Cancel)
+ mbox.addButton(QMessageBox.Help)
+ mbox.addButton("Settings...", QMessageBox.ResetRole)
+ mbox.addButton("Auto Install", QMessageBox.AcceptRole)
+ mbox.setDefaultButton(0)
+ answer = mbox.exec()
+ log.info(answer)
+ if answer == 1:
+ log.info("Installing...")
+ try:
+ self.fbworker.installDependencies()
+ log.info("Installation successful!")
+ return True
+ except Exception as e:
+ log.error('%s %s', type(e), e)
+ QMessageBox.critical(None, "Error", 'There has been an error while trying to install the required components on the tablet.\n%s\n.' % e)
+ self.quit()
+ elif answer == QMessageBox.Cancel:
+ self.quit()
+ elif answer == QMessageBox.Help:
+ QDesktopServices.openUrl(QUrl("https://github.com/bordaigorl/rmview"))
+ self.quit()
+ else:
+ self.openSettings(prompt=False)
+
+ return False
+
@pyqtSlot(QImage)
def onNewFrame(self, image):
- if self.orient:
+ if self.orient > 0:
self.detectOrientation(image)
- self.orient = False
+ if self.orient == 1:
+ self.orient = 0
self.viewer.setImage(image)
@pyqtSlot()
def hidePen(self):
if self.trail is not None:
self.trail = False
+ self.pen.lastShown = None
self.pen.hide()
@pyqtSlot()
def showPen(self):
if self.trail is not None:
self.trail = False
+ self.pen.lastShown = time.perf_counter()
+ # self.pen.show()
+
+ @pyqtSlot()
+ def showPenNow(self):
+ if self.trail is not None:
+ self.trail = False
+ self.pen.lastShown = None
self.pen.show()
@pyqtSlot(int, int)
@@ -337,13 +429,38 @@ def movePen(self, x, y):
QTimer.singleShot(self.trailDelay // 2, lambda: t.setOpacity(.5))
QTimer.singleShot(self.trailDelay, lambda: self.viewer.scene.removeItem(t))
self.pen.setRect(x - (self.pen_size // 2), y - (self.pen_size // 2), self.pen_size, self.pen_size)
+ if self.pen.lastShown is not None:
+ if time.perf_counter() - self.pen.lastShown > self.pen.showDelay:
+ self.pen.show()
+ self.pen.lastShown = None
@pyqtSlot()
def cloneViewer(self):
img = self.viewer.image()
+ img = QPixmap.fromImage(img)
+ img.detach()
v = QtImageViewer()
+ v.setAttribute(Qt.WA_DeleteOnClose)
v.setImage(img)
v.show()
+ v.rotate(self.viewer._rotation)
+ self.cloned_frames.add(v)
+ v.destroyed.connect(lambda: self.cloned_frames.discard(v))
+
+ @pyqtSlot()
+ def toggleStreaming(self):
+ if self.streaming:
+ self.fbworker.pause()
+ self.penworker.pause()
+ self.streaming = False
+ self.pauseAction.setText("Resume Streaming")
+ self.viewer.setWindowTitle("rMview - " + self.ssh.hostname + " [PAUSED]")
+ else:
+ self.fbworker.resume()
+ self.penworker.resume()
+ self.streaming = True
+ self.pauseAction.setText("Pause Streaming")
+ self.viewer.setWindowTitle("rMview - " + self.ssh.hostname)
@pyqtSlot()
def openSettings(self, prompt=True):
@@ -446,11 +563,27 @@ def frameError(self, e):
QMessageBox.critical(self.viewer, "Error", 'Please check your reMarkable is properly configured, see the documentation for instructions.\n\n%s' % e)
self.quit()
+ def event(self, e):
+ return QApplication.event(self, e)
+
def rmViewMain():
log.setLevel(logging.INFO)
+ if len(sys.argv) > 1:
+ if sys.argv[1] == "-v":
+ log.setLevel(logging.DEBUG)
+ del sys.argv[1]
+ elif sys.argv[1] == "-q":
+ log.setLevel(logging.ERROR)
+ del sys.argv[1]
+
+ log.info("STARTING: %s", time.asctime())
QCoreApplication.setAttribute(Qt.AA_EnableHighDpiScaling)
- ecode = rMViewApp(sys.argv).exec_()
- print('\nBye!')
+ app = rMViewApp(sys.argv)
+ # We register custom signal handler so we can gracefuly stop app with CTRL+C when QT main loop is
+ # running
+ signal.signal(signal.SIGINT, lambda *args: app.quit())
+ ecode = app.exec_()
+ log.info("QUITTING: %s", time.asctime())
sys.exit(ecode)
if __name__ == '__main__':
diff --git a/src/rmview/screenstream/__init__.py b/src/rmview/screenstream/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/rmview/screenstream/common.py b/src/rmview/screenstream/common.py
new file mode 100644
index 0000000..949fe72
--- /dev/null
+++ b/src/rmview/screenstream/common.py
@@ -0,0 +1,80 @@
+import logging
+import atexit
+
+from PyQt5.QtGui import *
+from PyQt5.QtCore import *
+
+from twisted.internet import reactor
+
+from rmview.rmparams import *
+from rmview.rfb import *
+
+try:
+ IMG_FORMAT = QImage.Format_Grayscale16
+except Exception:
+ IMG_FORMAT = QImage.Format_RGB16
+BYTES_PER_PIXEL = 2
+
+log = logging.getLogger('rmview')
+
+
+class ScreenStreamSignals(QObject):
+ onFatalError = pyqtSignal(Exception)
+ onNewFrame = pyqtSignal(QImage)
+
+
+class VncClient(RFBClient):
+ img = QImage(WIDTH, HEIGHT, IMG_FORMAT)
+ painter = QPainter(img)
+
+ def __init__(self, signals):
+ super(VncClient, self).__init__()
+ self.signals = signals
+
+ def emitImage(self):
+ self.signals.onNewFrame.emit(self.img)
+
+ def vncConnectionMade(self):
+ log.info("Connection to VNC server has been established")
+
+ # self.signals = self.factory.signals
+ self.setEncodings([
+ HEXTILE_ENCODING,
+ CORRE_ENCODING,
+ PSEUDO_CURSOR_ENCODING,
+ RRE_ENCODING,
+ ZRLE_ENCODING,
+ RAW_ENCODING ])
+ self.framebufferUpdateRequest()
+
+ def sendPassword(self, password):
+ self.signals.onFatalError.emit(Exception("Unsupported password request."))
+
+ def commitUpdate(self, rectangles=None):
+ self.signals.onNewFrame.emit(self.img)
+ self.framebufferUpdateRequest(incremental=1)
+
+ def updateRectangle(self, x, y, width, height, data):
+ self.painter.drawImage(x,y,QImage(data, width, height, width * BYTES_PER_PIXEL, IMG_FORMAT))
+
+
+class VncFactory(RFBFactory):
+ protocol = VncClient
+ instance = None
+
+ def __init__(self, signals):
+ super(VncFactory, self).__init__()
+ self.signals = signals
+
+ def buildProtocol(self, addr):
+ self.instance = VncClient(self.signals)
+ self.instance.factory = self
+ return self.instance
+
+ def clientConnectionLost(self, connector, reason):
+ log.warning("Disconnected: %s", reason.getErrorMessage())
+ reactor.callFromThread(reactor.stop)
+
+ def clientConnectionFailed(self, connector, reason):
+ self.signals.onFatalError.emit(Exception("Connection failed: " + str(reason)))
+ reactor.callFromThread(reactor.stop)
diff --git a/src/rmview/screenstream/screenshare.py b/src/rmview/screenstream/screenshare.py
new file mode 100644
index 0000000..511c695
--- /dev/null
+++ b/src/rmview/screenstream/screenshare.py
@@ -0,0 +1,75 @@
+from PyQt5.QtGui import *
+from PyQt5.QtWidgets import *
+from PyQt5.QtCore import *
+
+from rmview.rmparams import *
+
+import paramiko
+import struct
+import time
+
+import sys
+import os
+import logging
+
+from twisted.internet.protocol import Protocol
+from twisted.internet import protocol, reactor, ssl
+from twisted.application import internet, service
+
+from rmview.screenstream.common import *
+
+log = logging.getLogger('rmview')
+
+
+class ScreenShareStream(QRunnable):
+
+ factory = None
+
+ def __init__(self, ssh):
+ super(ScreenShareStream, self).__init__()
+ self.ssh = ssh
+ self.signals = ScreenStreamSignals()
+
+ def needsDependencies(self):
+ return False
+
+ def installDependencies(self):
+ pass
+
+ def stop(self):
+ log.info("Stopping framebuffer thread...")
+ reactor.callFromThread(reactor.stop)
+
+ @pyqtSlot()
+ def run(self):
+ log.info("Connecting to ScreenShare (make sure it's enabled!)")
+ try:
+ self.factory = VncFactory(self.signals)
+ #left for testing with stunnel
+ #self.vncClient = internet.TCPClient("localhost", 31337, self.factory)
+ self.vncClient = internet.SSLClient(self.ssh.hostname, 5900, self.factory, ssl.ClientContextFactory())
+ self.vncClient.startService()
+ reactor.run(installSignalHandlers=0)
+ except Exception as e:
+ log.error(e)
+
+ @pyqtSlot()
+ def pause(self):
+ self.signals.blockSignals(True)
+
+ @pyqtSlot()
+ def resume(self):
+ self.signals.blockSignals(False)
+ try:
+ self.factory.instance.emitImage()
+ except Exception:
+ log.warning("Not ready to resume")
+
+ def pointerEvent(self, x, y, button):
+ pass
+
+ def keyEvent(self, key):
+ pass
+
+ def emulatePressRelease(self, key):
+ pass
diff --git a/src/rmview/screenstream/vnc.py b/src/rmview/screenstream/vnc.py
new file mode 100644
index 0000000..1c75c1c
--- /dev/null
+++ b/src/rmview/screenstream/vnc.py
@@ -0,0 +1,247 @@
+import logging
+import atexit
+
+from PyQt5.QtGui import *
+from PyQt5.QtCore import *
+
+from twisted.internet import reactor
+from twisted.application import internet
+
+from rmview.screenstream.common import *
+# from rmview.rmparams import *
+# from rmview.rfb import *
+
+log = logging.getLogger('rmview')
+
+
+class VncStreamer(QRunnable):
+
+ _stop = False
+
+ ignoreEvents = False
+ factory = None
+ vncClient = None
+ sshTunnel = None
+
+ def __init__(self, ssh, ssh_config, delay=None):
+ super(VncStreamer, self).__init__()
+ self.ssh = ssh
+ self.ssh_config = ssh_config
+ self.use_ssh_tunnel = self.ssh_config.get("tunnel", False)
+
+ self._vnc_server_already_running = False
+
+ self.signals = ScreenStreamSignals()
+
+ def needsDependencies(self):
+ _, out, _ = self.ssh.exec_command("[ -x $HOME/rM-vnc-server-standalone ]")
+ log.info("%s %s", QFile, QIODevice)
+ return out.channel.recv_exit_status() != 0
+
+ def installDependencies(self):
+ sftp = self.ssh.open_sftp()
+ from stat import S_IXUSR
+ fo = QFile(':bin/rM%d-vnc-server-standalone' % ssh.deviceVersion)
+ fo.open(QIODevice.ReadOnly)
+ sftp.putfo(fo, 'rM-vnc-server-standalone')
+ fo.close()
+ sftp.chmod('rM-vnc-server-standalone', S_IXUSR)
+
+ def stop(self):
+ if self._stop:
+ # Already stopped
+ return
+
+ self._stop = True
+
+ log.debug("Stopping framebuffer thread...")
+
+ if self.vncClient:
+ try:
+ log.info("Disconnecting from VNC server...")
+ reactor.callFromThread(self.vncClient.stopService)
+ except Exception as e:
+ log.debug("Disconnect failed (%s), stopping reactor" % str(e))
+ reactor.callFromThread(reactor.stop)
+
+ # If we used an existing running instance and didn't start one ourselves we will not kill it.
+ if not self._vnc_server_already_running:
+ try:
+ log.info("Stopping VNC server...")
+ self.ssh.exec_command("killall -SIGINT rM-vnc-server-standalone")
+ except Exception as e:
+ log.warning("VNC could not be stopped on the reMarkable.")
+ log.warning("Although this is not a big problem, it may consume some resources until you restart the tablet.")
+ log.warning("You can manually terminate it by running `ssh root@%s killall rM-vnc-server-standalone`.", self.ssh.hostname)
+ log.error(e)
+
+ if self.sshTunnel:
+ try:
+ log.info("Stopping SSH tunnel...")
+ self.sshTunnel.stop()
+ except Exception as e:
+ log.error(e)
+
+ log.debug("Framebuffer thread stopped")
+
+ @pyqtSlot()
+ def run(self):
+ try:
+ self._start_vnc_server()
+ vnc_server_host, vnc_server_port = self._setup_ssh_tunnel_if_configured()
+ except Exception as e:
+ self.signals.onFatalError.emit(e)
+ return
+
+ log.info("Establishing connection to remote VNC server on %s:%s" % (vnc_server_host,
+ vnc_server_port))
+ try:
+ self.factory = VncFactory(self.signals)
+ self.vncClient = internet.TCPClient(vnc_server_host, vnc_server_port, self.factory)
+ self.vncClient.startService()
+ reactor.run(installSignalHandlers=0)
+ except Exception as e:
+ log.error("Failed to connect to the VNC server: %s" % (str(e)))
+
+ def _check_vnc_server_is_already_running(self) -> bool:
+ """
+ Check if VNC server is already running on reMarkable.
+
+ If it is, True is returned by this method and a log message if emitted.
+ """
+ _, stdout, stderr = self.ssh.exec_command("ps -ww | grep rM-vnc-server-standalone | grep -v grep")
+
+ stdout_bytes = stdout.read()
+
+ if b"rM-vnc-server-standalone" in stdout_bytes:
+ # TODO: Add config option to force kill and start a fresh server in this case
+ vnc_server_already_running = True
+ log.info("Found an existing instance of rM-vnc-server-standalone process on reMarkable. "
+ "Will try to use that instance instead of starting a new one.")
+
+ if self.use_ssh_tunnel and b"-listen localhost" not in stdout_bytes:
+ # If user has configured SSH tunnel, but existing VNC server instance is not using "-listen
+ # localhost" flag this likely indicates that the running server is listening on all the
+ # interfaces. This could pose a security risk so we log a warning.
+ log.warn("Existing VNC server is not running with \"-listen localhost\" flag. This means "
+ "that the existing server is likely listening on all the interfaces. This could "
+ "pose a security risk so you are advised to run server with \"-listen localhost\" "
+ "flag when using an SSH tunnel.")
+ else:
+ vnc_server_already_running = False
+
+ return vnc_server_already_running
+
+ def _start_vnc_server(self):
+ """
+ Start VNC server on reMarkable if it's not already running.
+ """
+ self._vnc_server_already_running = self._check_vnc_server_is_already_running()
+
+ if self._vnc_server_already_running:
+ # Server already running, we will try to use that instance
+ return
+
+ if self.use_ssh_tunnel:
+ # If using SSH tunnel, we ensure VNC server only listens on localhost. That's important for
+ # security reasons.
+ server_run_cmd = "$HOME/rM-vnc-server-standalone -listen localhost"
+ else:
+ server_run_cmd = "$HOME/rM-vnc-server-standalone"
+
+ log.info("Starting VNC server (command=%s)" % (server_run_cmd))
+
+ _, _, stdout = self.ssh.exec_command(server_run_cmd)
+
+ # TODO: This method for consuming stdout is not really good, it assumed there will always be
+ # at least one line produced...
+ # And we should also check exit code and not stdout for better robustness.
+ stdout_bytes = next(stdout).strip()
+ log.info("Start command stdout output: %s" % (stdout_bytes))
+
+ if "listening for vnc connections on" not in stdout_bytes.lower():
+ raise Exception("Failed to start VNC server on reMarkable: %s" % (stdout_bytes))
+
+ # Register atexit handler to ensure we always try to kill started server on exit
+ atexit.register(self.stop)
+
+
+ def _setup_ssh_tunnel_if_configured(self):
+ """
+ Set up and start SSH tunnel (if configured).
+ """
+ if self.use_ssh_tunnel:
+ tunnel = self._get_ssh_tunnel()
+ tunnel.start()
+ self.sshTunnel = tunnel
+
+ log.info("Setting up SSH tunnel %s:%s (rm) <-> %s:%s (localhost)" % ("127.0.0.1", 5900,
+ tunnel.local_bind_host,
+ tunnel.local_bind_port))
+
+ vnc_server_host = tunnel.local_bind_host
+ vnc_server_port = tunnel.local_bind_port
+ else:
+ vnc_server_host = self.ssh.hostname
+ vnc_server_port = 5900
+
+
+ return (vnc_server_host, vnc_server_port)
+
+ def _get_ssh_tunnel(self):
+ open_tunnel_kwargs = {
+ "ssh_username" : self.ssh_config.get("username", "root"),
+ }
+
+ if self.ssh_config.get("auth_method", "password") == "key":
+ open_tunnel_kwargs["ssh_pkey"] = self.ssh_config["key"]
+
+ if self.ssh_config.get("password", None):
+ open_tunnel_kwargs["ssh_private_key_password"] = self.ssh_config["password"]
+ else:
+ open_tunnel_kwargs["ssh_password"] = self.ssh_config["password"]
+
+ try:
+ import sshtunnel
+ except ModuleNotFoundError:
+ raise Exception("You need to install `sshtunnel` to use the tunnel feature")
+ tunnel = sshtunnel.open_tunnel(
+ (self.ssh.hostname, 22),
+ remote_bind_address=("127.0.0.1", 5900),
+ # We don't specify port so library auto assigns random unused one in the high range
+ local_bind_address=('127.0.0.1',),
+ compression=self.ssh_config.get("tunnel_compression", False),
+ **open_tunnel_kwargs)
+
+ return tunnel
+
+ @pyqtSlot()
+ def pause(self):
+ self.ignoreEvents = True
+ self.signals.blockSignals(True)
+
+ @pyqtSlot()
+ def resume(self):
+ self.ignoreEvents = False
+ self.signals.blockSignals(False)
+ try:
+ self.factory.instance.emitImage()
+ except Exception:
+ log.warning("Not ready to resume")
+
+ # @pyqtSlot(int,int,int)
+ def pointerEvent(self, x, y, button):
+ if self.ignoreEvents: return
+ try:
+ reactor.callFromThread(self.factory.instance.pointerEvent, x, y, button)
+ except Exception as e:
+ log.warning("Not ready to send pointer events! [%s]", e)
+
+ def keyEvent(self, key):
+ if self.ignoreEvents: return
+ reactor.callFromThread(self.emulatePressRelease, key)
+
+ def emulatePressRelease(self, key):
+ self.factory.instance.keyEvent(key)
+ # time.sleep(.1)
+ self.factory.instance.keyEvent(key, 0)
diff --git a/src/rmview/viewer.py b/src/rmview/viewer.py
index f235542..03ea352 100644
--- a/src/rmview/viewer.py
+++ b/src/rmview/viewer.py
@@ -1,16 +1,24 @@
-from PyQt5.QtCore import Qt, QRectF, pyqtSignal, QT_VERSION_STR
-from PyQt5.QtGui import QWindow, QImage, QPixmap, QTransform, QIcon
-from PyQt5.QtWidgets import QApplication, QGraphicsView, QGraphicsScene, QFileDialog, QAction, QMenu
+from PyQt5.QtCore import *
+from PyQt5.QtGui import *
+from PyQt5.QtWidgets import *
class QtImageViewer(QGraphicsView):
+ pointerEvent = pyqtSignal(int, int, int)
+ _button = 0
+
zoomInFactor = 1.25
zoomOutFactor = 1 / zoomInFactor
def __init__(self):
QGraphicsView.__init__(self)
- # self.setAttribute(Qt.WA_OpaquePaintEvent, True)
+ self.setFrameStyle(QFrame.NoFrame)
+
+ self.setRenderHint(QPainter.Antialiasing)
+ self.setRenderHint(QPainter.SmoothPixmapTransform)
+
+ self.viewport().grabGesture(Qt.PinchGesture)
self.scene = QGraphicsScene()
self.setScene(self.scene)
@@ -21,39 +29,53 @@ def __init__(self):
self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self.setAlignment(Qt.AlignCenter)
- self.menu = QMenu(self)
- act = QAction('Fit to view', self, checkable=True)
- self.fitAction = act
- act.triggered.connect(lambda: self.setFit(True))
- self.menu.addAction(act)
+ ### ACTIONS
+ self.fitAction = QAction('Fit to view', self, checkable=True)
+ self.fitAction.setShortcut("Ctrl+0")
+ self.fitAction.triggered.connect(lambda: self.setFit(True))
+ self.addAction(self.fitAction)
###
- act = QAction('Actual Size', self)
- act.triggered.connect(lambda: self.actualSize())
- self.menu.addAction(act)
+ self.actualSizeAction = QAction('Actual Size', self)
+ self.actualSizeAction.setShortcut("Ctrl+1")
+ self.actualSizeAction.triggered.connect(lambda: self.actualSize())
+ self.addAction(self.actualSizeAction)
###
- act = QAction('Zoom In', self)
- act.triggered.connect(self.zoomIn)
- self.menu.addAction(act)
+ self.zoomInAction = QAction('Zoom In', self)
+ self.zoomInAction.setShortcut(QKeySequence.ZoomIn)
+ self.zoomInAction.triggered.connect(self.zoomIn)
+ self.addAction(self.zoomInAction)
###
- act = QAction('Zoom Out', self)
- act.triggered.connect(self.zoomOut)
- self.menu.addAction(act)
+ self.zoomOutAction = QAction('Zoom Out', self)
+ self.zoomOutAction.setShortcut(QKeySequence.ZoomOut)
+ self.zoomOutAction.triggered.connect(self.zoomOut)
+ self.addAction(self.zoomOutAction)
###
- self.menu.addSeparator() # --------------------------
+ self.rotCWAction = QAction('Rotate clockwise', self)
+ self.rotCWAction.setShortcut("Ctrl+R")
+ self.rotCWAction.triggered.connect(self.rotateCW)
+ self.addAction(self.rotCWAction)
###
- act = QAction('Rotate clockwise', self)
- act.triggered.connect(self.rotateCW)
- self.menu.addAction(act)
+ self.rotCCWAction = QAction('Rotate counter-clockwise', self)
+ self.rotCCWAction.setShortcut("Ctrl+L")
+ self.rotCCWAction.triggered.connect(self.rotateCCW)
+ self.addAction(self.rotCCWAction)
###
- act = QAction('Rotate counter-clockwise', self)
- act.triggered.connect(self.rotateCCW)
- self.menu.addAction(act)
+ self.screenshotAction = QAction('Save screenshot', self)
+ self.screenshotAction.setShortcut(QKeySequence.Save)
+ self.screenshotAction.triggered.connect(self.screenshot)
+ self.addAction(self.screenshotAction)
###
+
+ self.menu = QMenu(self)
+ self.menu.addAction(self.fitAction)
+ self.menu.addAction(self.actualSizeAction)
+ self.menu.addAction(self.zoomInAction)
+ self.menu.addAction(self.zoomOutAction)
self.menu.addSeparator() # --------------------------
- ###
- act = QAction('Save screenshot', self)
- act.triggered.connect(self.screenshot)
- self.menu.addAction(act)
+ self.menu.addAction(self.rotCWAction)
+ self.menu.addAction(self.rotCCWAction)
+ self.menu.addSeparator() # --------------------------
+ self.menu.addAction(self.screenshotAction)
self._fit = True
self._rotation = 0 # used to produce a rotated screenshot
@@ -107,6 +129,25 @@ def updateViewer(self):
def resizeEvent(self, event):
self.updateViewer()
+ def mousePressEvent(self, event):
+ if event.button() == Qt.LeftButton:
+ scenePos = self.mapToScene(event.pos())
+ if int(event.modifiers()) & int(Qt.ControlModifier):
+ self._button = 1
+ else:
+ self._button = 4
+ self.pointerEvent.emit(int(scenePos.x()), int(scenePos.y()), self._button)
+
+ def mouseReleaseEvent(self, event):
+ scenePos = self.mapToScene(event.pos())
+ self._button = 0
+ self.pointerEvent.emit(int(scenePos.x()), int(scenePos.y()), 0)
+
+ def mouseMoveEvent(self, event):
+ if self._button > 0:
+ scenePos = self.mapToScene(event.pos())
+ self.pointerEvent.emit(int(scenePos.x()), int(scenePos.y()), self._button)
+
def mouseDoubleClickEvent(self, event):
# scenePos = self.mapToScene(event.pos())
if event.button() == Qt.LeftButton:
@@ -117,6 +158,14 @@ def mouseDoubleClickEvent(self, event):
# self.rightMouseButtonDoubleClicked.emit(scenePos.x(), scenePos.y())
QGraphicsView.mouseDoubleClickEvent(self, event)
+ def viewportEvent(self, event):
+ if event.type() == QEvent.Gesture:
+ pinch = event.gesture(Qt.PinchGesture)
+ if pinch is not None:
+ self._fit = False
+ self.scale(pinch.scaleFactor(), pinch.scaleFactor())
+ return True
+ return bool(QGraphicsView.viewportEvent(self, event))
def wheelEvent(self, event):
if event.modifiers() == Qt.NoModifier:
@@ -151,12 +200,18 @@ def screenshot(self):
img = img.transformed(QTransform().rotate(self._rotation))
img.save(fileName)
+ def is_landscape(self):
+ return self._rotation == 90
+
def landscape(self):
self.resetTransform()
self.rotate(90)
self._rotation = 90
self.updateViewer()
+ def is_portrait(self):
+ return self._rotation == 0
+
def portrait(self):
self.resetTransform()
self._rotation = 0
@@ -197,11 +252,7 @@ def actualSize(self):
self.rotate(self._rotation)
def keyPressEvent(self, event):
- if event.key() == Qt.Key_Left:
- self.rotateCCW()
- elif event.key() == Qt.Key_Right:
- self.rotateCW()
- elif event.key() == Qt.Key_F:
+ if event.key() == Qt.Key_F:
self.setFit(True)
elif event.key() == Qt.Key_1:
self.actualSize()
diff --git a/src/rmview/workers.py b/src/rmview/workers.py
deleted file mode 100644
index 9c5f1f1..0000000
--- a/src/rmview/workers.py
+++ /dev/null
@@ -1,201 +0,0 @@
-from PyQt5.QtGui import *
-from PyQt5.QtWidgets import *
-from PyQt5.QtCore import *
-
-from .rmparams import *
-
-import paramiko
-import struct
-import time
-
-import sys
-import os
-import logging
-
-
-from twisted.internet.protocol import Protocol
-from twisted.internet import protocol, reactor
-from twisted.application import internet, service
-
-from .rfb import *
-
-try:
- IMG_FORMAT = QImage.Format_Grayscale16
-except Exception:
- IMG_FORMAT = QImage.Format_RGB16
-BYTES_PER_PIXEL = 2
-
-log = logging.getLogger('rmview')
-
-class FBWSignals(QObject):
- onFatalError = pyqtSignal(Exception)
- onNewFrame = pyqtSignal(QImage)
-
-
-class RFB(RFBClient):
- img = QImage(WIDTH, HEIGHT, IMG_FORMAT)
- painter = QPainter(img)
-
- def vncConnectionMade(self):
- self.signals = self.factory.signals
- self.setEncodings([
- HEXTILE_ENCODING,
- CORRE_ENCODING,
- PSEUDO_CURSOR_ENCODING,
- RRE_ENCODING,
- RAW_ENCODING ])
- time.sleep(.1) # get first image without artifacts
- self.framebufferUpdateRequest()
-
- def sendPassword(self, password):
- self.signals.onFatalError.emit(Exception("Unsupported password request."))
-
- def commitUpdate(self, rectangles=None):
- self.signals.onNewFrame.emit(self.img)
- self.framebufferUpdateRequest(incremental=1)
-
- def updateRectangle(self, x, y, width, height, data):
- self.painter.drawImage(x,y,QImage(data, width, height, width * BYTES_PER_PIXEL, IMG_FORMAT))
-
-
-
-class RFBFactory(RFBFactory):
- protocol = RFB
-
- def __init__(self, signals):
- super(RFBFactory, self).__init__()
- self.signals = signals
-
- def clientConnectionLost(self, connector, reason):
- log.warning("Connection lost: %s", reason.getErrorMessage())
- connector.connect()
-
- def clientConnectionFailed(self, connector, reason):
- self.signals.onFatalError.emit(Exception("Connection failed: " + str(reason)))
- reactor.callFromThread(reactor.stop)
-
-
-class FrameBufferWorker(QRunnable):
-
- _stop = False
-
- def __init__(self, ssh, delay=None, lz4_path=None, img_format=IMG_FORMAT):
- super(FrameBufferWorker, self).__init__()
- self.ssh = ssh
- self.img_format = img_format
-
- self.signals = FBWSignals()
-
- def stop(self):
- self._stop = True
- log.info("Stopping framebuffer thread...")
- reactor.callFromThread(reactor.stop)
- try:
- self.ssh.exec_command("killall rM-vnc-server-standalone", timeout=3)
- except Exception as e:
- log.warning("VNC could not be stopped on the reMarkable.")
- log.warning("Although this is not a big problem, it may consume some resources until you restart the tablet.")
- log.warning("You can manually terminate it by running `ssh %s killall rM-vnc-server-standalone`.", self.ssh.hostname)
- log.error(e)
- log.info("Framebuffer thread stopped")
-
- @pyqtSlot()
- def run(self):
- try:
- _,_,out = self.ssh.exec_command("$HOME/rM-vnc-server-standalone")
- log.info(next(out))
- except Exception as e:
- self.signals.onFatalError.emit(e)
-
- while self._stop == False:
- log.info("Starting VNC server")
- try:
- self.vncClient = internet.TCPClient(self.ssh.hostname, 5900, RFBFactory(self.signals))
- self.vncClient.startService()
- reactor.run(installSignalHandlers=0)
- except Exception as e:
- log.error(e)
-
-
-class PWSignals(QObject):
- onFatalError = pyqtSignal(Exception)
- onPenMove = pyqtSignal(int, int)
- onPenPress = pyqtSignal()
- onPenLift = pyqtSignal()
- onPenNear = pyqtSignal()
- onPenFar = pyqtSignal()
-
-LIFTED = 0
-PRESSED = 1
-
-
-class PointerWorker(QRunnable):
-
- _stop = False
-
- def __init__(self, ssh, path="/dev/input/event0", threshold=1000):
- super(PointerWorker, self).__init__()
- self.event = path
- self.ssh = ssh
- self.threshold = threshold
- self.signals = PWSignals()
-
- def stop(self):
- self._penkill.write('\n')
- self._stop = True
-
- @pyqtSlot()
- def run(self):
- penkill, penstream, _ = self.ssh.exec_command('cat %s & { read ; kill %%1; }' % self.event)
- self._penkill = penkill
- new_x = new_y = False
- state = LIFTED
-
- while not self._stop:
- try:
- _, _, e_type, e_code, e_value = struct.unpack('2IHHi', penstream.read(16))
- except struct.error:
- return
- except Exception as e:
- log.error('Error in pointer worker: %s %s', type(e), e)
- return
-
- # decoding adapted from remarkable_mouse
- if e_type == e_type_abs:
-
-
- # handle x direction
- if e_code == e_code_stylus_xpos:
- x = e_value
- new_x = True
-
- # handle y direction
- if e_code == e_code_stylus_ypos:
- y = e_value
- new_y = True
-
- # handle draw
- if e_code == e_code_stylus_pressure:
- if e_value > self.threshold:
- if state == LIFTED:
- log.debug('PRESS')
- state = PRESSED
- self.signals.onPenPress.emit()
- else:
- if state == PRESSED:
- log.debug('RELEASE')
- state = LIFTED
- self.signals.onPenLift.emit()
-
- if new_x and new_y:
- self.signals.onPenMove.emit(x, y)
- new_x = new_y = False
-
- if e_type == e_type_key and e_code == e_code_stylus_proximity:
- if e_value == 0:
- self.signals.onPenFar.emit()
- else:
- self.signals.onPenNear.emit()
-
-
-