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.
- joystream-0.0.1/.gitignore +3 -0
- joystream-0.0.1/PKG-INFO +126 -0
- joystream-0.0.1/README.md +109 -0
- joystream-0.0.1/index.html +166 -0
- joystream-0.0.1/joystream.py +186 -0
- joystream-0.0.1/pyproject.toml +35 -0
joystream-0.0.1/PKG-INFO
ADDED
|
@@ -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"]
|