joystream 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
@@ -0,0 +1,126 @@
1
+ Metadata-Version: 2.5
2
+ Name: joystream
3
+ Version: 0.0.1
4
+ Summary: Turn a phone's touchscreen into a Linux gamepad
5
+ Author-email: Jonas Eschmann <jonas.eschmann@gmail.com>
6
+ License: MIT
7
+ Keywords: evdev,gamepad,joystick,touchscreen,uinput,websocket
8
+ Classifier: Environment :: Console
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: POSIX :: Linux
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Games/Entertainment
13
+ Requires-Python: >=3.9
14
+ Requires-Dist: evdev>=1.6
15
+ Requires-Dist: websockets>=13
16
+ Description-Content-Type: text/markdown
17
+
18
+ # joystream
19
+
20
+ Turn a phone's touchscreen into a Linux gamepad.
21
+
22
+ One Python process serves a touch-gamepad web page and takes the phone's input
23
+ back over a websocket on the same port. Every update is written to a virtual
24
+ gamepad created through `/dev/uinput`, so games, SDL2, `jstest`, and anything
25
+ else on the box see an ordinary controller.
26
+
27
+ ```
28
+ phone browser --http--> joystream.py --uinput--> /dev/input/eventN, /dev/input/jsN
29
+ index.html --ws---->
30
+ ```
31
+
32
+ ## Install
33
+
34
+ Two dependencies, both packaged by most distros:
35
+
36
+ ```
37
+ sudo apt install python3-websockets python3-evdev # Debian / Ubuntu
38
+ # or
39
+ pip install -r requirements.txt # evdev builds a small C extension
40
+ ```
41
+
42
+ You need write access to `/dev/uinput`. On Ubuntu it is `root:input 0660`, so
43
+ either add yourself to the `input` group (log out and in again):
44
+
45
+ ```
46
+ sudo usermod -aG input $USER
47
+ ```
48
+
49
+ or drop a udev rule for a group of your choice:
50
+
51
+ ```
52
+ echo 'KERNEL=="uinput", GROUP="input", MODE="0660"' | sudo tee /etc/udev/rules.d/99-uinput.rules
53
+ sudo udevadm control --reload && sudo modprobe uinput
54
+ ```
55
+
56
+ ## Run
57
+
58
+ ```
59
+ python3 joystream.py # --port 8000 --host 0.0.0.0 by default
60
+ ```
61
+
62
+ It prints the URL to open on the phone, e.g. `http://10.0.0.5:8000`. Phone and
63
+ computer must be on the same network. Plain HTTP is fine: touch input and
64
+ websockets do not need HTTPS on iOS.
65
+
66
+ On the phone:
67
+
68
+ - Type the URL **with `http://`**. Recent iOS Safari tries `https://` first for
69
+ addresses typed without a scheme; the server closes such attempts at once so
70
+ Safari falls back, but typing the scheme avoids the detour entirely. The
71
+ terminal shows a line for every page request and for every HTTPS attempt, so
72
+ if nothing appears there when the phone tries, the phone cannot reach the
73
+ computer (different Wi-Fi, client isolation on the access point, or a
74
+ firewall). A Tailscale IP works too when both devices are on it.
75
+ - Hold it in landscape. Touch anywhere on the left half for the left stick and
76
+ anywhere on the right half for the right stick. The stick centers where your
77
+ finger lands, so touchdown is always neutral.
78
+ - A/B/X/Y, L1/R1, Select and Start are buttons. Two sticks and a button can be
79
+ held at the same time.
80
+ - Safari: Share, then "Add to Home Screen". Opening it from there gives a true
81
+ full-screen page without the address bar or the edge-swipe back gesture.
82
+ - If the page ever ends up zoomed in (iOS lets some gestures past the page's
83
+ guards), reload it: zoom resets on load. From the Home Screen version, close
84
+ the app from the app switcher and open it again.
85
+ - Set Auto-Lock to Never while playing. The Screen Wake Lock API needs HTTPS,
86
+ so the page cannot keep the screen on by itself.
87
+
88
+ ## Verify on the Linux side
89
+
90
+ ```
91
+ evtest # pick "joystream", move a stick, press buttons
92
+ jstest --normal /dev/input/js0
93
+ ```
94
+
95
+ Or open any browser Gamepad API tester on the Linux machine. The device uses
96
+ the standard evdev gamepad codes (`BTN_SOUTH` and friends, `ABS_X/Y/RX/RY`), so
97
+ udev tags it `ID_INPUT_JOYSTICK` and SDL2 maps it to the standard controller
98
+ layout without a mapping file.
99
+
100
+ ## Behaviour worth knowing
101
+
102
+ - **Failsafe.** The page sends its full state at least every 100 ms. If the
103
+ server hears nothing for 0.5 s (Wi-Fi drop, phone locked, tab backgrounded)
104
+ it sets every axis to zero and releases every button. Same on disconnect.
105
+ - **One phone at a time.** A new connection takes over and the previous one is
106
+ closed. The virtual device itself is created once at startup and stays put
107
+ across reconnects, so programs that bind to a joystick at launch keep
108
+ working.
109
+ - **Protocol.** One JSON object per message, all fields optional, missing
110
+ fields mean neutral:
111
+
112
+ ```json
113
+ {"lx":0.0,"ly":-0.5,"rx":0.0,"ry":0.0,"a":1,"b":0,"x":0,"y":0,"l1":0,"r1":0,"select":0,"start":0}
114
+ ```
115
+
116
+ Axes are -1..1 with y positive downward (the Linux convention: stick up is
117
+ negative). Buttons are 0 or 1.
118
+
119
+ ## Customising
120
+
121
+ - Add or move controls in the `STICKS` and `BUTTONS` tables at the top of the
122
+ script in `index.html`. Positions are percentages of the screen, sizes are
123
+ in `vmin` in the CSS.
124
+ - Add new inputs on the server in the `BUTTONS` and `AXES` dicts in
125
+ `joystream.py` (for a D-pad use `ABS_HAT0X/Y`, for triggers `ABS_Z/RZ`).
126
+ - `FAILSAFE_S` in `joystream.py` sets the silence timeout.
@@ -0,0 +1,109 @@
1
+ # joystream
2
+
3
+ Turn a phone's touchscreen into a Linux gamepad.
4
+
5
+ One Python process serves a touch-gamepad web page and takes the phone's input
6
+ back over a websocket on the same port. Every update is written to a virtual
7
+ gamepad created through `/dev/uinput`, so games, SDL2, `jstest`, and anything
8
+ else on the box see an ordinary controller.
9
+
10
+ ```
11
+ phone browser --http--> joystream.py --uinput--> /dev/input/eventN, /dev/input/jsN
12
+ index.html --ws---->
13
+ ```
14
+
15
+ ## Install
16
+
17
+ Two dependencies, both packaged by most distros:
18
+
19
+ ```
20
+ sudo apt install python3-websockets python3-evdev # Debian / Ubuntu
21
+ # or
22
+ pip install -r requirements.txt # evdev builds a small C extension
23
+ ```
24
+
25
+ You need write access to `/dev/uinput`. On Ubuntu it is `root:input 0660`, so
26
+ either add yourself to the `input` group (log out and in again):
27
+
28
+ ```
29
+ sudo usermod -aG input $USER
30
+ ```
31
+
32
+ or drop a udev rule for a group of your choice:
33
+
34
+ ```
35
+ echo 'KERNEL=="uinput", GROUP="input", MODE="0660"' | sudo tee /etc/udev/rules.d/99-uinput.rules
36
+ sudo udevadm control --reload && sudo modprobe uinput
37
+ ```
38
+
39
+ ## Run
40
+
41
+ ```
42
+ python3 joystream.py # --port 8000 --host 0.0.0.0 by default
43
+ ```
44
+
45
+ It prints the URL to open on the phone, e.g. `http://10.0.0.5:8000`. Phone and
46
+ computer must be on the same network. Plain HTTP is fine: touch input and
47
+ websockets do not need HTTPS on iOS.
48
+
49
+ On the phone:
50
+
51
+ - Type the URL **with `http://`**. Recent iOS Safari tries `https://` first for
52
+ addresses typed without a scheme; the server closes such attempts at once so
53
+ Safari falls back, but typing the scheme avoids the detour entirely. The
54
+ terminal shows a line for every page request and for every HTTPS attempt, so
55
+ if nothing appears there when the phone tries, the phone cannot reach the
56
+ computer (different Wi-Fi, client isolation on the access point, or a
57
+ firewall). A Tailscale IP works too when both devices are on it.
58
+ - Hold it in landscape. Touch anywhere on the left half for the left stick and
59
+ anywhere on the right half for the right stick. The stick centers where your
60
+ finger lands, so touchdown is always neutral.
61
+ - A/B/X/Y, L1/R1, Select and Start are buttons. Two sticks and a button can be
62
+ held at the same time.
63
+ - Safari: Share, then "Add to Home Screen". Opening it from there gives a true
64
+ full-screen page without the address bar or the edge-swipe back gesture.
65
+ - If the page ever ends up zoomed in (iOS lets some gestures past the page's
66
+ guards), reload it: zoom resets on load. From the Home Screen version, close
67
+ the app from the app switcher and open it again.
68
+ - Set Auto-Lock to Never while playing. The Screen Wake Lock API needs HTTPS,
69
+ so the page cannot keep the screen on by itself.
70
+
71
+ ## Verify on the Linux side
72
+
73
+ ```
74
+ evtest # pick "joystream", move a stick, press buttons
75
+ jstest --normal /dev/input/js0
76
+ ```
77
+
78
+ Or open any browser Gamepad API tester on the Linux machine. The device uses
79
+ the standard evdev gamepad codes (`BTN_SOUTH` and friends, `ABS_X/Y/RX/RY`), so
80
+ udev tags it `ID_INPUT_JOYSTICK` and SDL2 maps it to the standard controller
81
+ layout without a mapping file.
82
+
83
+ ## Behaviour worth knowing
84
+
85
+ - **Failsafe.** The page sends its full state at least every 100 ms. If the
86
+ server hears nothing for 0.5 s (Wi-Fi drop, phone locked, tab backgrounded)
87
+ it sets every axis to zero and releases every button. Same on disconnect.
88
+ - **One phone at a time.** A new connection takes over and the previous one is
89
+ closed. The virtual device itself is created once at startup and stays put
90
+ across reconnects, so programs that bind to a joystick at launch keep
91
+ working.
92
+ - **Protocol.** One JSON object per message, all fields optional, missing
93
+ fields mean neutral:
94
+
95
+ ```json
96
+ {"lx":0.0,"ly":-0.5,"rx":0.0,"ry":0.0,"a":1,"b":0,"x":0,"y":0,"l1":0,"r1":0,"select":0,"start":0}
97
+ ```
98
+
99
+ Axes are -1..1 with y positive downward (the Linux convention: stick up is
100
+ negative). Buttons are 0 or 1.
101
+
102
+ ## Customising
103
+
104
+ - Add or move controls in the `STICKS` and `BUTTONS` tables at the top of the
105
+ script in `index.html`. Positions are percentages of the screen, sizes are
106
+ in `vmin` in the CSS.
107
+ - Add new inputs on the server in the `BUTTONS` and `AXES` dicts in
108
+ `joystream.py` (for a D-pad use `ABS_HAT0X/Y`, for triggers `ABS_Z/RZ`).
109
+ - `FAILSAFE_S` in `joystream.py` sets the silence timeout.
@@ -0,0 +1,166 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
6
+ <meta name="apple-mobile-web-app-capable" content="yes">
7
+ <meta name="mobile-web-app-capable" content="yes">
8
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
9
+ <title>joystream</title>
10
+ <style>
11
+ * { box-sizing: border-box; margin: 0; padding: 0; }
12
+ html, body { height: 100%; overflow: hidden; background: #111; color: #777;
13
+ font-family: -apple-system, system-ui, sans-serif; }
14
+ body { position: fixed; inset: 0; touch-action: none; overscroll-behavior: none;
15
+ -webkit-user-select: none; user-select: none; -webkit-touch-callout: none;
16
+ -webkit-tap-highlight-color: transparent; }
17
+
18
+ /* Left/right halves: touching anywhere starts a floating stick there. */
19
+ .zone { position: absolute; top: 0; bottom: 0; width: 50%; touch-action: none; }
20
+ #lzone { left: 0; }
21
+ #rzone { right: 0; }
22
+
23
+ .stick { position: absolute; width: 32vmin; height: 32vmin; border-radius: 50%;
24
+ background: #1c1c1c; border: 2px solid #333; transform: translate(-50%, -50%);
25
+ pointer-events: none; }
26
+ .knob { position: absolute; left: 50%; top: 50%; width: 14vmin; height: 14vmin;
27
+ border-radius: 50%; background: #444; transform: translate(-50%, -50%); }
28
+ .stick.active { border-color: #4af; }
29
+ .stick.active .knob { background: #4af; }
30
+
31
+ .btn { position: absolute; transform: translate(-50%, -50%); z-index: 1;
32
+ display: flex; align-items: center; justify-content: center;
33
+ width: 11vmin; height: 11vmin; border-radius: 50%;
34
+ font-weight: 700; font-size: 4vmin; color: #bbb;
35
+ background: #1c1c1c; border: 2px solid var(--c, #444); touch-action: none; }
36
+ .btn.pressed { background: var(--c, #666); color: #111; }
37
+ .btn.pill { width: 14vmin; height: 6vmin; border-radius: 3vmin; font-size: 2.4vmin; }
38
+ .btn.bar { width: 26vmin; height: 8vmin; border-radius: 2vmin; font-size: 3vmin; }
39
+
40
+ #status { position: absolute; left: 50%; bottom: 1.5vmin; transform: translateX(-50%);
41
+ font-size: 2.5vmin; color: #c44; }
42
+ #status.ok { color: #4c4; }
43
+
44
+ #rotate { position: absolute; inset: 0; z-index: 10; display: none;
45
+ align-items: center; justify-content: center; background: #111;
46
+ font-size: 5vmin; color: #999; }
47
+ @media (orientation: portrait) { #rotate { display: flex; } }
48
+ </style>
49
+ </head>
50
+ <body>
51
+ <div class="zone" id="lzone"></div>
52
+ <div class="zone" id="rzone"></div>
53
+ <div id="status">connecting…</div>
54
+ <div id="rotate">rotate to landscape</div>
55
+ <script>
56
+ 'use strict';
57
+
58
+ // Positions are % of the screen; sizes come from CSS (vmin units).
59
+ const STICKS = [
60
+ { zone: 'lzone', x: '22%', y: '66%', ax: 'lx', ay: 'ly' },
61
+ { zone: 'rzone', x: '78%', y: '66%', ax: 'rx', ay: 'ry' },
62
+ ];
63
+ const BUTTONS = [
64
+ { key: 'y', label: 'Y', x: 'calc(82% + 0vmin)', y: 'calc(30% - 9vmin)', color: '#ec4' },
65
+ { key: 'x', label: 'X', x: 'calc(82% - 9vmin)', y: '30%', color: '#48f' },
66
+ { key: 'b', label: 'B', x: 'calc(82% + 9vmin)', y: '30%', color: '#e44' },
67
+ { key: 'a', label: 'A', x: 'calc(82% + 0vmin)', y: 'calc(30% + 9vmin)', color: '#4c4' },
68
+ { key: 'l1', label: 'L1', x: '14%', y: '9%', cls: 'bar' },
69
+ { key: 'r1', label: 'R1', x: '86%', y: '9%', cls: 'bar' },
70
+ { key: 'select', label: 'SELECT', x: '44%', y: '9%', cls: 'pill' },
71
+ { key: 'start', label: 'START', x: '56%', y: '9%', cls: 'pill' },
72
+ ];
73
+
74
+ // Full state snapshot; the server writes all of it on every message.
75
+ const state = { lx: 0, ly: 0, rx: 0, ry: 0, a: 0, b: 0, x: 0, y: 0, l1: 0, r1: 0, select: 0, start: 0 };
76
+
77
+ let ws = null;
78
+ const statusEl = document.getElementById('status');
79
+
80
+ function send() {
81
+ if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(state));
82
+ }
83
+
84
+ function connect() {
85
+ ws = new WebSocket(`ws://${location.host}/ws`);
86
+ ws.onopen = () => { statusEl.textContent = 'connected'; statusEl.classList.add('ok'); send(); };
87
+ ws.onclose = () => {
88
+ statusEl.textContent = 'disconnected, retrying…'; statusEl.classList.remove('ok');
89
+ setTimeout(connect, 1000);
90
+ };
91
+ }
92
+ connect();
93
+ setInterval(send, 100); // heartbeat so the server's failsafe can tell "idle" from "gone"
94
+
95
+ const round = v => Math.round(v * 1000) / 1000;
96
+
97
+ function makeStick(cfg) {
98
+ const zone = document.getElementById(cfg.zone);
99
+ const base = document.createElement('div'); base.className = 'stick';
100
+ const knob = document.createElement('div'); knob.className = 'knob';
101
+ base.appendChild(knob); document.body.appendChild(base);
102
+ base.style.left = cfg.x; base.style.top = cfg.y;
103
+
104
+ let id = null, ox = 0, oy = 0; // active pointer and its touchdown origin
105
+ const set = (x, y) => { state[cfg.ax] = round(x); state[cfg.ay] = round(y); send(); };
106
+ const move = e => {
107
+ const r = base.offsetWidth / 2;
108
+ let dx = e.clientX - ox, dy = e.clientY - oy;
109
+ const d = Math.hypot(dx, dy);
110
+ if (d > r) { dx *= r / d; dy *= r / d; }
111
+ knob.style.transform = `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px))`;
112
+ set(dx / r, dy / r); // screen y grows downward, same as Linux ABS_Y
113
+ };
114
+ const end = e => {
115
+ if (e.pointerId !== id) return;
116
+ id = null;
117
+ base.style.left = cfg.x; base.style.top = cfg.y; base.classList.remove('active');
118
+ knob.style.transform = 'translate(-50%, -50%)';
119
+ set(0, 0);
120
+ };
121
+ zone.addEventListener('pointerdown', e => {
122
+ if (id !== null) return;
123
+ id = e.pointerId; ox = e.clientX; oy = e.clientY;
124
+ zone.setPointerCapture(id);
125
+ base.style.left = ox + 'px'; base.style.top = oy + 'px'; base.classList.add('active');
126
+ move(e);
127
+ });
128
+ zone.addEventListener('pointermove', e => { if (e.pointerId === id) move(e); });
129
+ zone.addEventListener('pointerup', end);
130
+ zone.addEventListener('pointercancel', end);
131
+ }
132
+
133
+ function makeButton(cfg) {
134
+ const b = document.createElement('div');
135
+ b.className = 'btn' + (cfg.cls ? ' ' + cfg.cls : '');
136
+ b.textContent = cfg.label;
137
+ b.style.left = cfg.x; b.style.top = cfg.y;
138
+ if (cfg.color) b.style.setProperty('--c', cfg.color);
139
+ const set = v => { state[cfg.key] = v; b.classList.toggle('pressed', v === 1); send(); };
140
+ b.addEventListener('pointerdown', e => { b.setPointerCapture(e.pointerId); set(1); });
141
+ b.addEventListener('pointerup', () => set(0));
142
+ b.addEventListener('pointercancel', () => set(0));
143
+ document.body.appendChild(b);
144
+ }
145
+
146
+ STICKS.forEach(makeStick);
147
+ BUTTONS.forEach(makeButton);
148
+
149
+ // Belt and braces against iOS gestures that touch-action and the viewport meta
150
+ // do not reliably stop (pinch, scroll, callout and above all double-tap zoom,
151
+ // which iOS still performs in Home Screen mode). Cancelled touch events do not
152
+ // affect pointer events, so the sticks and buttons keep working. Double-tap
153
+ // zoom is recognised on the second tap's touchend, so that is the one to cancel.
154
+ document.addEventListener('touchstart', e => e.preventDefault(), { passive: false });
155
+ document.addEventListener('touchmove', e => e.preventDefault(), { passive: false });
156
+ let lastTouchEnd = 0;
157
+ document.addEventListener('touchend', e => {
158
+ const now = Date.now();
159
+ if (now - lastTouchEnd < 350) e.preventDefault();
160
+ lastTouchEnd = now;
161
+ }, { passive: false });
162
+ document.addEventListener('gesturestart', e => e.preventDefault());
163
+ document.addEventListener('contextmenu', e => e.preventDefault());
164
+ </script>
165
+ </body>
166
+ </html>
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env python3
2
+ """joystream: turn a phone's touchscreen into a Linux gamepad.
3
+
4
+ Serves index.html over HTTP and accepts gamepad state over a websocket on the
5
+ same port. Every state message is written to a virtual gamepad created through
6
+ /dev/uinput, so any Linux program sees an ordinary controller.
7
+ """
8
+ import argparse
9
+ import asyncio
10
+ import json
11
+ import logging
12
+ import socket
13
+ import time
14
+ from pathlib import Path
15
+
16
+ from evdev import AbsInfo, InputDevice, UInput, ecodes as e, list_devices
17
+ from websockets.asyncio.server import ServerConnection, serve
18
+ from websockets.datastructures import Headers
19
+ from websockets.exceptions import ConnectionClosed, InvalidMessage
20
+ from websockets.http11 import Response
21
+
22
+ # Client state keys -> evdev codes. These are the standard gamepad codes, so
23
+ # SDL2 and friends recognise the device as a normal controller with no mapping.
24
+ BUTTONS = {
25
+ "a": e.BTN_SOUTH, "b": e.BTN_EAST, "x": e.BTN_WEST, "y": e.BTN_NORTH,
26
+ "l1": e.BTN_TL, "r1": e.BTN_TR,
27
+ "select": e.BTN_SELECT, "start": e.BTN_START,
28
+ }
29
+ AXES = {"lx": e.ABS_X, "ly": e.ABS_Y, "rx": e.ABS_RX, "ry": e.ABS_RY}
30
+ AXIS_MAX = 32767
31
+ FAILSAFE_S = 0.5 # go neutral if the phone stays silent this long
32
+ PAD_NAME = "joystream"
33
+
34
+ INDEX = Path(__file__).with_name("index.html")
35
+
36
+
37
+ def log(msg):
38
+ print(time.strftime("%H:%M:%S"), msg, flush=True)
39
+
40
+
41
+ class QuietPreconnects(logging.Filter):
42
+ """Drop websockets' traceback for TCP connections that close before sending
43
+ an HTTP request. Safari opens speculative connections like that on every
44
+ page load; they are harmless and would otherwise spam the terminal."""
45
+
46
+ def filter(self, record):
47
+ exc = record.exc_info[1] if record.exc_info else None
48
+ return not isinstance(exc, InvalidMessage)
49
+
50
+
51
+ def make_pad():
52
+ absinfo = AbsInfo(value=0, min=-AXIS_MAX, max=AXIS_MAX, fuzz=0, flat=0, resolution=0)
53
+ return UInput(
54
+ {e.EV_KEY: list(BUTTONS.values()),
55
+ e.EV_ABS: [(code, absinfo) for code in AXES.values()]},
56
+ name=PAD_NAME, bustype=e.BUS_VIRTUAL,
57
+ )
58
+
59
+
60
+ def find_node(name):
61
+ """Best-effort lookup of /dev/input/eventN for our device (needs read access)."""
62
+ for path in list_devices():
63
+ try:
64
+ if InputDevice(path).name == name:
65
+ return path
66
+ except OSError:
67
+ pass
68
+ return "(node not readable, see README permissions)"
69
+
70
+
71
+ def apply(pad, state):
72
+ """Write a full state snapshot. Missing keys mean neutral / released."""
73
+ for key, code in AXES.items():
74
+ v = max(-1.0, min(1.0, float(state.get(key, 0))))
75
+ pad.write(e.EV_ABS, code, int(v * AXIS_MAX))
76
+ for key, code in BUTTONS.items():
77
+ pad.write(e.EV_KEY, code, 1 if state.get(key) else 0)
78
+ pad.syn()
79
+
80
+
81
+ class Server:
82
+ def __init__(self, pad):
83
+ self.pad = pad
84
+ self.client = None # the one connection currently driving the pad
85
+
86
+ async def handle(self, ws):
87
+ peer = ws.remote_address[0]
88
+ if self.client is not None:
89
+ log(f"{peer} takes over from {self.client.remote_address[0]}")
90
+ asyncio.ensure_future(self.client.close())
91
+ self.client = ws
92
+ log(f"{peer} connected")
93
+ silent = False
94
+ try:
95
+ while True:
96
+ try:
97
+ msg = await asyncio.wait_for(ws.recv(), FAILSAFE_S)
98
+ except asyncio.TimeoutError:
99
+ if not silent:
100
+ log(f"{peer} silent for {FAILSAFE_S}s, pad set to neutral")
101
+ silent = True
102
+ apply(self.pad, {})
103
+ continue
104
+ silent = False
105
+ try:
106
+ apply(self.pad, json.loads(msg))
107
+ except (ValueError, TypeError, AttributeError):
108
+ pass # malformed message, ignore
109
+ except ConnectionClosed:
110
+ pass
111
+ finally:
112
+ if self.client is ws:
113
+ self.client = None
114
+ apply(self.pad, {})
115
+ log(f"{peer} disconnected")
116
+
117
+
118
+ class PadConnection(ServerConnection):
119
+ """Peek at the first bytes: a TLS ClientHello means the phone tried https://.
120
+ Closing at once makes Safari fall back to http:// instead of hanging."""
121
+ peeked = False
122
+
123
+ def data_received(self, data):
124
+ if not self.peeked:
125
+ self.peeked = True
126
+ if data[:1] == b"\x16":
127
+ log(f"{self.remote_address[0]} tried HTTPS, open the http:// URL instead")
128
+ self.transport.abort()
129
+ return
130
+ super().data_received(data)
131
+
132
+
133
+ def process_request(conn, request):
134
+ """Answer plain HTTP GETs for the page; let /ws continue as a websocket."""
135
+ if request.path == "/ws":
136
+ return None
137
+ log(f"{conn.remote_address[0]} GET {request.path} ({request.headers.get('User-Agent', '?')})")
138
+ if request.path in ("/", "/index.html"):
139
+ body = INDEX.read_bytes()
140
+ return Response(200, "OK", Headers([
141
+ ("Content-Type", "text/html; charset=utf-8"),
142
+ ("Content-Length", str(len(body))),
143
+ ("Connection", "close"),
144
+ ]), body)
145
+ return Response(404, "Not Found", Headers([("Content-Length", "0"), ("Connection", "close")]))
146
+
147
+
148
+ def lan_ip():
149
+ try:
150
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
151
+ s.connect(("10.255.255.255", 1))
152
+ return s.getsockname()[0]
153
+ except OSError:
154
+ return "127.0.0.1"
155
+
156
+
157
+ async def main(host, port):
158
+ logging.getLogger("websockets.server").addFilter(QuietPreconnects())
159
+ pad = make_pad()
160
+ try:
161
+ apply(pad, {})
162
+ await asyncio.sleep(0.2) # let udev create the node before we look for it
163
+ log(f"virtual gamepad created: {find_node(PAD_NAME)}")
164
+ srv = Server(pad)
165
+ async with serve(srv.handle, host, port, process_request=process_request,
166
+ create_connection=PadConnection, compression=None,
167
+ ping_interval=5, ping_timeout=5):
168
+ log(f"open http://{lan_ip()}:{port} on your phone")
169
+ await asyncio.get_running_loop().create_future()
170
+ finally:
171
+ pad.close()
172
+
173
+
174
+ def cli():
175
+ ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
176
+ ap.add_argument("--host", default="0.0.0.0")
177
+ ap.add_argument("--port", type=int, default=8000)
178
+ args = ap.parse_args()
179
+ try:
180
+ asyncio.run(main(args.host, args.port))
181
+ except KeyboardInterrupt:
182
+ pass
183
+
184
+
185
+ if __name__ == "__main__":
186
+ cli()
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "joystream"
7
+ version = "0.0.1"
8
+ description = "Turn a phone's touchscreen into a Linux gamepad"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ authors = [{name = "Jonas Eschmann", email = "jonas.eschmann@gmail.com"}]
12
+ license = {text = "MIT"}
13
+ keywords = ["gamepad", "joystick", "uinput", "evdev", "touchscreen", "websocket"]
14
+ classifiers = [
15
+ "Environment :: Console",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: POSIX :: Linux",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Games/Entertainment",
20
+ ]
21
+ dependencies = [
22
+ "websockets>=13",
23
+ "evdev>=1.6",
24
+ ]
25
+
26
+ [project.scripts]
27
+ joystream = "joystream:cli"
28
+
29
+ # Single-module layout: joystream.py reads index.html from the directory it
30
+ # lives in, so both files must be shipped side by side.
31
+ [tool.hatch.build.targets.wheel]
32
+ only-include = ["joystream.py", "index.html"]
33
+
34
+ [tool.hatch.build.targets.sdist]
35
+ only-include = ["joystream.py", "index.html", "README.md"]