Skip to content

Commit

Permalink
Add paste API endpoint (#1625)
Browse files Browse the repository at this point in the history
Related #1026

This PR adds an API endpoint that accepts text, parses each text
character to a HID keystroke, and asynchronously sends all keystrokes to
the target machine.

### Notes
1. By writing the keystrokes in a background thread, separate from main
thread that runs the HTTP server and SocketIO server, we avoid blocking
HTTP requests and WebSocket messages ([that unexpectedly disconnected
the client during a large
paste](#1026)).
* The background thread that writes the pasted text, is run in a "fire
and forget" manner which means we no longer report on the success or
failure of the HID file IO. However, a single write error will abort
further keystrokes from being written.

### Peer testing
You can test this PR on device via the following stacked PR:
* #1626

<a data-ca-tag
href="https://codeapprove.com/pr/tiny-pilot/tinypilot/1625"><img
src="https://codeapprove.com/external/github-tag-allbg.png" alt="Review
on CodeApprove" /></a>
  • Loading branch information
jdeanwallace authored Sep 19, 2023
1 parent 3a7fe4a commit da6f5f0
Show file tree
Hide file tree
Showing 8 changed files with 195 additions and 0 deletions.
33 changes: 33 additions & 0 deletions app/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,20 @@

import db.settings
import debug_logs
import execute
import hostname
import json_response
import local_system
import request_parsers.errors
import request_parsers.hostname
import request_parsers.paste
import request_parsers.video_settings
import update.launcher
import update.settings
import update.status
import version
import video_service
from hid import keyboard as fake_keyboard

api_blueprint = flask.Blueprint('api', __name__, url_prefix='/api')

Expand Down Expand Up @@ -327,3 +330,33 @@ def settings_video_apply_post():
video_service.restart()

return json_response.success()


@api_blueprint.route('/paste', methods=['POST'])
def paste_post():
"""Pastes text onto the target machine.
Expects a JSON data structure in the request body that contains the
following parameters:
- text: string
- language: string as an IETF language tag
Example of request body:
{
"text": "Hello, World!",
"language": "en-US"
}
Returns:
Empty response on success, error object otherwise.
"""
try:
keystrokes = request_parsers.paste.parse_keystrokes(flask.request)
except request_parsers.errors.Error as e:
return json_response.error(e), 400

keyboard_path = flask.current_app.config.get('KEYBOARD_PATH')
execute.background_thread(fake_keyboard.send_keystrokes,
args=(keyboard_path, keystrokes))

return json_response.success()
17 changes: 17 additions & 0 deletions app/execute.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import dataclasses
import multiprocessing
import threading
import typing


Expand Down Expand Up @@ -88,3 +89,19 @@ def _wait_for_process_exit(target_process):
max_attempts = 3
for _ in range(max_attempts):
target_process.join(timeout=0.1)


def background_thread(function, args=None):
"""Runs the given function in a background thread.
Note: The function is executed in a "fire and forget" manner.
Args:
function: The function to be executed in a thread.
args: Optional `function` arguments as a tuple.
"""
# Never wait or join a regular thread because it will block the SocketIO
# server:
# https://github.com/miguelgrinberg/Flask-SocketIO/issues/1264#issuecomment-620653614
thread = threading.Thread(target=function, args=args or ())
thread.start()
6 changes: 6 additions & 0 deletions app/execute_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,9 @@ def test_execute_with_timeout_child_exception(self):
with self.assertRaises(Exception) as ctx:
execute.with_timeout(raise_exception, timeout_in_seconds=0.5)
self.assertEqual('Child exception', str(ctx.exception))

def test_background_thread_ignores_function_successful(self):
self.assertEqual(None, execute.background_thread(return_string))

def test_background_thread_ignores_function_exception(self):
self.assertEqual(None, execute.background_thread(raise_exception))
14 changes: 14 additions & 0 deletions app/hid/keyboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,17 @@ def send_keystroke(keyboard_path, keystroke):

def release_keys(keyboard_path):
hid_write.write_to_hid_interface(keyboard_path, [0] * 8)


def send_keystrokes(keyboard_path, keystrokes):
"""Sends multiple keystrokes to the HID interface, one after the other.
Args:
keyboard_path: The file path to the keyboard interface.
keystrokes: A list of HID Keystroke objects.
Raises:
WriteError: If a keystroke fails to be written to the HID interface.
"""
for keystroke in keystrokes:
send_keystroke(keyboard_path, keystroke)
16 changes: 16 additions & 0 deletions app/hid/keyboard_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,19 @@ def test_send_release_keys_to_hid_interface(self):
keyboard.release_keys(keyboard_path=input_file.name)
self.assertEqual(b'\x00\x00\x00\x00\x00\x00\x00\x00',
input_file.read())

def test_send_multiple_keystrokes_to_hid_interface(self):
with tempfile.NamedTemporaryFile() as input_file:
keyboard.send_keystrokes(keyboard_path=input_file.name,
keystrokes=[
hid.Keystroke(keycode=hid.KEYCODE_A),
hid.Keystroke(keycode=hid.KEYCODE_B),
hid.Keystroke(keycode=hid.KEYCODE_C)
])
self.assertEqual(
b'\x00\x00\x04\x00\x00\x00\x00\x00'
b'\x00\x00\x00\x00\x00\x00\x00\x00'
b'\x00\x00\x05\x00\x00\x00\x00\x00'
b'\x00\x00\x00\x00\x00\x00\x00\x00'
b'\x00\x00\x06\x00\x00\x00\x00\x00'
b'\x00\x00\x00\x00\x00\x00\x00\x00', input_file.read())
4 changes: 4 additions & 0 deletions app/request_parsers/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,7 @@ class InvalidHostnameError(Error):

class InvalidVideoSettingError(Error):
pass


class UnsupportedPastedCharacterError(Error):
pass
44 changes: 44 additions & 0 deletions app/request_parsers/paste.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import text_to_hid
from request_parsers import errors
from request_parsers import json


def parse_keystrokes(request):
"""
Parses HID keystrokes from the request.
Args:
request: Flask request with the following fields in the JSON body:
(str) text
(str) language
Returns:
A list of HID keystrokes.
Raises:
UnsupportedPastedCharacterError: If a pasted character cannot to be
converted to a HID keystroke.
"""
# pylint: disable=unbalanced-tuple-unpacking
(text,
language) = json.parse_json_body(request,
required_fields=['text', 'language'])
keystrokes = []
# Preserve the ordering of any unsupported characters found.
unsupported_chars_found = {}
for char in text:
try:
keystroke = text_to_hid.convert(char, language)
except text_to_hid.UnsupportedCharacterError:
unsupported_chars_found[char] = True
continue
# Skip ignored characters.
if keystroke is None:
continue
keystrokes.append(keystroke)
if unsupported_chars_found:
raise errors.UnsupportedPastedCharacterError(
f'These characters are not supported: '
f'{", ".join(map(repr, unsupported_chars_found.keys()))}')

return keystrokes
61 changes: 61 additions & 0 deletions app/request_parsers/paste_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import unittest
from unittest import mock

from hid import keycodes as hid
from request_parsers import errors
from request_parsers import paste


def make_mock_request(json_data):
mock_request = mock.Mock()
mock_request.get_json.return_value = json_data
return mock_request


class KeystrokesParserTest(unittest.TestCase):

def test_accepts(self):
self.assertEqual([
hid.Keystroke(keycode=hid.KEYCODE_A),
hid.Keystroke(keycode=hid.KEYCODE_B),
hid.Keystroke(keycode=hid.KEYCODE_C),
],
paste.parse_keystrokes(
make_mock_request({
'text': 'abc',
'language': 'en-US'
})))

def test_rejects_unsupported_character(self):
with self.assertRaises(errors.UnsupportedPastedCharacterError) as ctx:
paste.parse_keystrokes(
make_mock_request({
'text': 'Monday–Friday',
'language': 'en-US'
}))
self.assertEqual("""These characters are not supported: '–'""",
str(ctx.exception))

def test_rejects_unsupported_characters_preserving_order(self):
with self.assertRaises(errors.UnsupportedPastedCharacterError) as ctx:
paste.parse_keystrokes(
make_mock_request({
'text': '“Hello, World!” — Programmer',
'language': 'en-US'
}))
self.assertEqual(
"""These characters are not supported: '“', '”', '—'""",
str(ctx.exception))

def test_skips_ignored_character(self):
self.assertEqual([
hid.Keystroke(keycode=hid.KEYCODE_NUMBER_1),
hid.Keystroke(keycode=hid.KEYCODE_NUMBER_2),
hid.Keystroke(keycode=hid.KEYCODE_ENTER),
hid.Keystroke(keycode=hid.KEYCODE_NUMBER_3),
],
paste.parse_keystrokes(
make_mock_request({
'text': '12\r\n3',
'language': 'en-US'
})))

0 comments on commit da6f5f0

Please sign in to comment.