pocketwand 0.1.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.
- pocketwand-0.1.1/LICENSE +21 -0
- pocketwand-0.1.1/PKG-INFO +67 -0
- pocketwand-0.1.1/README.md +49 -0
- pocketwand-0.1.1/pyproject.toml +33 -0
- pocketwand-0.1.1/setup.cfg +4 -0
- pocketwand-0.1.1/src/pocketwand/__init__.py +7 -0
- pocketwand-0.1.1/src/pocketwand/cli.py +34 -0
- pocketwand-0.1.1/src/pocketwand/demo.py +168 -0
- pocketwand-0.1.1/src/pocketwand/orientation.py +100 -0
- pocketwand-0.1.1/src/pocketwand/room.py +180 -0
- pocketwand-0.1.1/src/pocketwand/server.py +98 -0
- pocketwand-0.1.1/src/pocketwand/static/controller.html +130 -0
- pocketwand-0.1.1/src/pocketwand/tunnel.py +86 -0
- pocketwand-0.1.1/src/pocketwand.egg-info/PKG-INFO +67 -0
- pocketwand-0.1.1/src/pocketwand.egg-info/SOURCES.txt +19 -0
- pocketwand-0.1.1/src/pocketwand.egg-info/dependency_links.txt +1 -0
- pocketwand-0.1.1/src/pocketwand.egg-info/entry_points.txt +2 -0
- pocketwand-0.1.1/src/pocketwand.egg-info/requires.txt +9 -0
- pocketwand-0.1.1/src/pocketwand.egg-info/top_level.txt +1 -0
- pocketwand-0.1.1/tests/test_orientation.py +52 -0
- pocketwand-0.1.1/tests/test_room.py +90 -0
pocketwand-0.1.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 agasthyako
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pocketwand
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Turn any phone into a motion controller, streamed through the browser.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/agasthyako/Pocket-Wand
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: aiohttp>=3.9
|
|
11
|
+
Requires-Dist: pycloudflared>=0.2
|
|
12
|
+
Requires-Dist: qrcode>=7.4
|
|
13
|
+
Provides-Extra: demo
|
|
14
|
+
Requires-Dist: pygame-ce>=2.4; extra == "demo"
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# PocketWand
|
|
20
|
+
|
|
21
|
+
Turn any phone into a motion controller for your Python program. The phone streams its rotation through its web browser, so there's no app to install, and it works on iPhone and Android, on any network (campus Wi-Fi included).
|
|
22
|
+
|
|
23
|
+
## Try the demo
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
pip install "pocketwand[demo] @ git+https://github.com/agasthyako/Pocket-Wand"
|
|
27
|
+
pocketwand demo
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
1. A window opens with a QR code (it takes a few seconds to get a public link).
|
|
31
|
+
2. Scan it with your phone's camera and open the link.
|
|
32
|
+
3. Tap **Enable motion** and allow access.
|
|
33
|
+
4. Point your phone at the screen and move it. The phone on screen copies it.
|
|
34
|
+
|
|
35
|
+
Press **R** (or the Recentre button on the phone) to make "pointing at the screen" count as straight ahead again.
|
|
36
|
+
|
|
37
|
+
`pocketwand print` does the same without a window and prints the Controller state to the terminal.
|
|
38
|
+
|
|
39
|
+
## Use it in your own Game
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
import pocketwand
|
|
43
|
+
|
|
44
|
+
room = pocketwand.Room() # starts the server and tunnel, prints a QR code
|
|
45
|
+
room.on_join = lambda c: print("joined slot", c.slot)
|
|
46
|
+
|
|
47
|
+
while True:
|
|
48
|
+
c = room.controller(0) # never None; an empty Slot reads as disconnected
|
|
49
|
+
if c.connected:
|
|
50
|
+
c.orientation # Quaternion(w, x, y, z)
|
|
51
|
+
c.orientation.matrix() # 3x3 rotation matrix
|
|
52
|
+
yaw, pitch, roll = c.euler # degrees
|
|
53
|
+
c.button # True while the on-screen button is held
|
|
54
|
+
# c.recenter() makes the current facing count as straight ahead
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**Frames.** Orientation rotates the phone's frame (+X right edge, +Y top of the phone, +Z out of the screen) into the world frame (+X right, +Y towards your screen, +Z up). Recentring only changes heading, so tilt always matches gravity.
|
|
58
|
+
|
|
59
|
+
**Several phones.** Every phone that opens the link gets its own Slot (`room.controller(1)`, …). A phone that reconnects gets its old Slot back. `room.controllers` lists the connected ones.
|
|
60
|
+
|
|
61
|
+
State is updated on a background thread; reading it from your game loop every frame is safe and cheap.
|
|
62
|
+
|
|
63
|
+
## How it works
|
|
64
|
+
|
|
65
|
+
`Room()` runs a small web server on your computer and opens a free [Cloudflare quick tunnel](https://try.cloudflare.com) to it (the `cloudflared` program is downloaded automatically on first run). The phone opens the page over HTTPS, which iOS requires before a page can read motion sensors, and streams its orientation back over a WebSocket. Both sides only make outgoing connections, so firewalls and campus client isolation don't get in the way.
|
|
66
|
+
|
|
67
|
+
Needs Python 3.10+ and an internet connection.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# PocketWand
|
|
2
|
+
|
|
3
|
+
Turn any phone into a motion controller for your Python program. The phone streams its rotation through its web browser, so there's no app to install, and it works on iPhone and Android, on any network (campus Wi-Fi included).
|
|
4
|
+
|
|
5
|
+
## Try the demo
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pip install "pocketwand[demo] @ git+https://github.com/agasthyako/Pocket-Wand"
|
|
9
|
+
pocketwand demo
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
1. A window opens with a QR code (it takes a few seconds to get a public link).
|
|
13
|
+
2. Scan it with your phone's camera and open the link.
|
|
14
|
+
3. Tap **Enable motion** and allow access.
|
|
15
|
+
4. Point your phone at the screen and move it. The phone on screen copies it.
|
|
16
|
+
|
|
17
|
+
Press **R** (or the Recentre button on the phone) to make "pointing at the screen" count as straight ahead again.
|
|
18
|
+
|
|
19
|
+
`pocketwand print` does the same without a window and prints the Controller state to the terminal.
|
|
20
|
+
|
|
21
|
+
## Use it in your own Game
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
import pocketwand
|
|
25
|
+
|
|
26
|
+
room = pocketwand.Room() # starts the server and tunnel, prints a QR code
|
|
27
|
+
room.on_join = lambda c: print("joined slot", c.slot)
|
|
28
|
+
|
|
29
|
+
while True:
|
|
30
|
+
c = room.controller(0) # never None; an empty Slot reads as disconnected
|
|
31
|
+
if c.connected:
|
|
32
|
+
c.orientation # Quaternion(w, x, y, z)
|
|
33
|
+
c.orientation.matrix() # 3x3 rotation matrix
|
|
34
|
+
yaw, pitch, roll = c.euler # degrees
|
|
35
|
+
c.button # True while the on-screen button is held
|
|
36
|
+
# c.recenter() makes the current facing count as straight ahead
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**Frames.** Orientation rotates the phone's frame (+X right edge, +Y top of the phone, +Z out of the screen) into the world frame (+X right, +Y towards your screen, +Z up). Recentring only changes heading, so tilt always matches gravity.
|
|
40
|
+
|
|
41
|
+
**Several phones.** Every phone that opens the link gets its own Slot (`room.controller(1)`, …). A phone that reconnects gets its old Slot back. `room.controllers` lists the connected ones.
|
|
42
|
+
|
|
43
|
+
State is updated on a background thread; reading it from your game loop every frame is safe and cheap.
|
|
44
|
+
|
|
45
|
+
## How it works
|
|
46
|
+
|
|
47
|
+
`Room()` runs a small web server on your computer and opens a free [Cloudflare quick tunnel](https://try.cloudflare.com) to it (the `cloudflared` program is downloaded automatically on first run). The phone opens the page over HTTPS, which iOS requires before a page can read motion sensors, and streams its orientation back over a WebSocket. Both sides only make outgoing connections, so firewalls and campus client isolation don't get in the way.
|
|
48
|
+
|
|
49
|
+
Needs Python 3.10+ and an internet connection.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pocketwand"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "Turn any phone into a motion controller, streamed through the browser."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
dependencies = [
|
|
14
|
+
"aiohttp>=3.9",
|
|
15
|
+
"pycloudflared>=0.2",
|
|
16
|
+
"qrcode>=7.4",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://github.com/agasthyako/Pocket-Wand"
|
|
21
|
+
|
|
22
|
+
[project.optional-dependencies]
|
|
23
|
+
demo = ["pygame-ce>=2.4"]
|
|
24
|
+
dev = ["pytest>=8"]
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
pocketwand = "pocketwand.cli:main"
|
|
28
|
+
|
|
29
|
+
[tool.setuptools.packages.find]
|
|
30
|
+
where = ["src"]
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.package-data]
|
|
33
|
+
pocketwand = ["static/*"]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def main() -> None:
|
|
5
|
+
parser = argparse.ArgumentParser(prog="pocketwand", description="Turn your phone into a motion controller.")
|
|
6
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
7
|
+
for name, help in (("demo", "open a window with a 3D phone that copies your phone's rotation"),
|
|
8
|
+
("print", "print Controller state to the terminal")):
|
|
9
|
+
p = sub.add_parser(name, help=help)
|
|
10
|
+
p.add_argument("--local", action="store_true",
|
|
11
|
+
help="skip the public tunnel and serve plain http on your network (phones won't send motion data without HTTPS)")
|
|
12
|
+
args = parser.parse_args()
|
|
13
|
+
|
|
14
|
+
if args.command == "demo":
|
|
15
|
+
from .demo import run
|
|
16
|
+
run(tunnel=not args.local)
|
|
17
|
+
else:
|
|
18
|
+
_print(tunnel=not args.local)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _print(tunnel: bool) -> None:
|
|
22
|
+
import time
|
|
23
|
+
|
|
24
|
+
from . import Room
|
|
25
|
+
|
|
26
|
+
room = Room(tunnel=tunnel)
|
|
27
|
+
room.on_join = lambda c: print(f"\njoined: slot {c.slot}")
|
|
28
|
+
room.on_leave = lambda c: print(f"\nleft: slot {c.slot}")
|
|
29
|
+
try:
|
|
30
|
+
while True:
|
|
31
|
+
print(f"\r{room.controller(0)!r} ", end="", flush=True)
|
|
32
|
+
time.sleep(1 / 20)
|
|
33
|
+
except KeyboardInterrupt:
|
|
34
|
+
room.close()
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Tech demo: a 3D phone on screen that copies the real phone's rotation 1:1."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
import pygame
|
|
8
|
+
import qrcode
|
|
9
|
+
|
|
10
|
+
from . import Room
|
|
11
|
+
|
|
12
|
+
W, H = 960, 720
|
|
13
|
+
BG = (14, 17, 22)
|
|
14
|
+
FG = (232, 237, 243)
|
|
15
|
+
DIM = (138, 150, 166)
|
|
16
|
+
BODY = (120, 130, 145)
|
|
17
|
+
SCREEN = (40, 110, 160)
|
|
18
|
+
SCREEN_HOT = (247, 37, 133)
|
|
19
|
+
LIGHT = (0.3, -0.5, 0.8)
|
|
20
|
+
|
|
21
|
+
# Phone half-sizes in phone frame: X across the screen, Y along the phone, Z out of the screen.
|
|
22
|
+
HX, HY, HZ = 0.38, 0.78, 0.05
|
|
23
|
+
CAMERA = (0.0, -3.2, 1.9)
|
|
24
|
+
FOCAL = 760
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def run(tunnel: bool = True) -> None:
|
|
28
|
+
room = Room(tunnel=tunnel)
|
|
29
|
+
pygame.init()
|
|
30
|
+
pygame.display.set_caption("PocketWand demo")
|
|
31
|
+
screen = pygame.display.set_mode((W, H))
|
|
32
|
+
font = pygame.font.SysFont("menlo,consolas,monospace", 18)
|
|
33
|
+
big = pygame.font.SysFont("helvetica,arial,sans", 28)
|
|
34
|
+
qr = _qr_surface(room.join_url, 10)
|
|
35
|
+
clock = pygame.time.Clock()
|
|
36
|
+
view = _camera_basis(CAMERA, (0.0, 0.0, 0.0))
|
|
37
|
+
|
|
38
|
+
running = True
|
|
39
|
+
while running:
|
|
40
|
+
for event in pygame.event.get():
|
|
41
|
+
if event.type == pygame.QUIT or (event.type == pygame.KEYDOWN and event.key in (pygame.K_ESCAPE, pygame.K_q)):
|
|
42
|
+
running = False
|
|
43
|
+
elif event.type == pygame.KEYDOWN and event.key == pygame.K_r:
|
|
44
|
+
room.controller(0).recenter()
|
|
45
|
+
|
|
46
|
+
c = room.controller(0)
|
|
47
|
+
screen.fill(BG)
|
|
48
|
+
if c.connected:
|
|
49
|
+
_draw_phone(screen, view, c.orientation.matrix(), c.button)
|
|
50
|
+
yaw, pitch, roll = c.euler
|
|
51
|
+
lines = [
|
|
52
|
+
f"slot {c.slot} button {'DOWN' if c.button else 'up'}",
|
|
53
|
+
f"yaw {yaw:7.1f} pitch {pitch:7.1f} roll {roll:7.1f}",
|
|
54
|
+
f"{clock.get_fps():.0f} fps R: recentre Q: quit",
|
|
55
|
+
]
|
|
56
|
+
for i, text in enumerate(lines):
|
|
57
|
+
screen.blit(font.render(text, True, DIM), (20, 20 + i * 24))
|
|
58
|
+
else:
|
|
59
|
+
title = big.render("Scan with your phone camera", True, FG)
|
|
60
|
+
screen.blit(title, title.get_rect(center=(W // 2, 70)))
|
|
61
|
+
screen.blit(qr, qr.get_rect(center=(W // 2, H // 2)))
|
|
62
|
+
url = font.render(room.join_url, True, DIM)
|
|
63
|
+
screen.blit(url, url.get_rect(center=(W // 2, H - 60)))
|
|
64
|
+
|
|
65
|
+
pygame.display.flip()
|
|
66
|
+
clock.tick(120)
|
|
67
|
+
|
|
68
|
+
pygame.quit()
|
|
69
|
+
room.close()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _draw_phone(screen, view, rot, hot: bool) -> None:
|
|
73
|
+
corners = [(sx * HX, sy * HY, sz * HZ) for sx in (-1, 1) for sy in (-1, 1) for sz in (-1, 1)]
|
|
74
|
+
world = [_mat_vec(rot, p) for p in corners]
|
|
75
|
+
cam = [_to_camera(view, p) for p in world]
|
|
76
|
+
|
|
77
|
+
def idx(sx, sy, sz):
|
|
78
|
+
return ((sx > 0) << 2) | ((sy > 0) << 1) | (sz > 0)
|
|
79
|
+
|
|
80
|
+
# Each face: (corner indices wound counter-clockwise from outside, phone-frame normal)
|
|
81
|
+
faces = [
|
|
82
|
+
([idx(-1, -1, 1), idx(1, -1, 1), idx(1, 1, 1), idx(-1, 1, 1)], (0, 0, 1)),
|
|
83
|
+
([idx(-1, 1, -1), idx(1, 1, -1), idx(1, -1, -1), idx(-1, -1, -1)], (0, 0, -1)),
|
|
84
|
+
([idx(1, -1, -1), idx(1, 1, -1), idx(1, 1, 1), idx(1, -1, 1)], (1, 0, 0)),
|
|
85
|
+
([idx(-1, -1, 1), idx(-1, 1, 1), idx(-1, 1, -1), idx(-1, -1, -1)], (-1, 0, 0)),
|
|
86
|
+
([idx(-1, 1, 1), idx(1, 1, 1), idx(1, 1, -1), idx(-1, 1, -1)], (0, 1, 0)),
|
|
87
|
+
([idx(-1, -1, -1), idx(1, -1, -1), idx(1, -1, 1), idx(-1, -1, 1)], (0, -1, 0)),
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
drawn = []
|
|
91
|
+
for quad, normal in faces:
|
|
92
|
+
n = _mat_vec(rot, normal)
|
|
93
|
+
centre = [sum(world[i][k] for i in quad) / 4 for k in range(3)]
|
|
94
|
+
to_cam = [CAMERA[k] - centre[k] for k in range(3)]
|
|
95
|
+
if _dot(n, to_cam) <= 0:
|
|
96
|
+
continue # facing away
|
|
97
|
+
depth = sum(cam[i][2] for i in quad) / 4
|
|
98
|
+
drawn.append((depth, quad, n, normal))
|
|
99
|
+
|
|
100
|
+
for depth, quad, n, normal in sorted(drawn, reverse=True):
|
|
101
|
+
shade = 0.35 + 0.65 * max(0.0, _dot(n, _normalise(LIGHT)))
|
|
102
|
+
pts = [_project(cam[i]) for i in quad]
|
|
103
|
+
pygame.draw.polygon(screen, _scale(BODY, shade), pts)
|
|
104
|
+
if normal == (0, 0, 1):
|
|
105
|
+
_draw_display(screen, view, rot, SCREEN_HOT if hot else SCREEN, shade)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _draw_display(screen, view, rot, colour, shade) -> None:
|
|
109
|
+
"""The glass on the front, plus a notch so you can tell the top from the bottom."""
|
|
110
|
+
z = HZ + 0.001
|
|
111
|
+
glass = [(-HX * 0.88, -HY * 0.92, z), (HX * 0.88, -HY * 0.92, z), (HX * 0.88, HY * 0.86, z), (-HX * 0.88, HY * 0.86, z)]
|
|
112
|
+
pygame.draw.polygon(screen, _scale(colour, shade), [_project(_to_camera(view, _mat_vec(rot, p))) for p in glass])
|
|
113
|
+
notch = [(-0.1, HY * 0.9, z), (0.1, HY * 0.9, z), (0.1, HY * 0.95, z), (-0.1, HY * 0.95, z)]
|
|
114
|
+
pygame.draw.polygon(screen, (20, 20, 20), [_project(_to_camera(view, _mat_vec(rot, p))) for p in notch])
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _qr_surface(data: str, cell: int) -> pygame.Surface:
|
|
118
|
+
qr = qrcode.QRCode(border=4) # the full 4-module quiet zone; iOS Camera ignores codes with less
|
|
119
|
+
qr.add_data(data)
|
|
120
|
+
matrix = qr.get_matrix()
|
|
121
|
+
size = len(matrix) * cell
|
|
122
|
+
surf = pygame.Surface((size, size))
|
|
123
|
+
surf.fill((255, 255, 255))
|
|
124
|
+
for y, row in enumerate(matrix):
|
|
125
|
+
for x, on in enumerate(row):
|
|
126
|
+
if on:
|
|
127
|
+
surf.fill((0, 0, 0), (x * cell, y * cell, cell, cell))
|
|
128
|
+
return surf
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _camera_basis(eye, target):
|
|
132
|
+
forward = _normalise([target[k] - eye[k] for k in range(3)])
|
|
133
|
+
right = _normalise(_cross(forward, (0.0, 0.0, 1.0)))
|
|
134
|
+
up = _cross(right, forward)
|
|
135
|
+
return right, up, forward
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _to_camera(view, p):
|
|
139
|
+
right, up, forward = view
|
|
140
|
+
rel = [p[k] - CAMERA[k] for k in range(3)]
|
|
141
|
+
return (_dot(rel, right), _dot(rel, up), _dot(rel, forward))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _project(p):
|
|
145
|
+
x, y, z = p
|
|
146
|
+
z = max(z, 0.01)
|
|
147
|
+
return (W / 2 + FOCAL * x / z, H / 2 - FOCAL * y / z)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _mat_vec(m, v):
|
|
151
|
+
return tuple(m[r][0] * v[0] + m[r][1] * v[1] + m[r][2] * v[2] for r in range(3))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _dot(a, b):
|
|
155
|
+
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _cross(a, b):
|
|
159
|
+
return (a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0])
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _normalise(v):
|
|
163
|
+
length = math.sqrt(_dot(v, v))
|
|
164
|
+
return tuple(x / length for x in v)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _scale(colour, k):
|
|
168
|
+
return tuple(min(255, int(c * k)) for c in colour)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Orientation maths.
|
|
2
|
+
|
|
3
|
+
World frame (after Recentre): +X right, +Y forward (towards the screen), +Z up.
|
|
4
|
+
Phone frame: +X right edge of the screen, +Y top of the phone, +Z out of the screen.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Quaternion:
|
|
15
|
+
w: float
|
|
16
|
+
x: float
|
|
17
|
+
y: float
|
|
18
|
+
z: float
|
|
19
|
+
|
|
20
|
+
@staticmethod
|
|
21
|
+
def identity() -> Quaternion:
|
|
22
|
+
return Quaternion(1.0, 0.0, 0.0, 0.0)
|
|
23
|
+
|
|
24
|
+
@staticmethod
|
|
25
|
+
def from_axis_angle(axis: tuple[float, float, float], degrees: float) -> Quaternion:
|
|
26
|
+
half = math.radians(degrees) / 2
|
|
27
|
+
s = math.sin(half)
|
|
28
|
+
return Quaternion(math.cos(half), axis[0] * s, axis[1] * s, axis[2] * s)
|
|
29
|
+
|
|
30
|
+
def __iter__(self):
|
|
31
|
+
return iter((self.w, self.x, self.y, self.z))
|
|
32
|
+
|
|
33
|
+
def __neg__(self) -> Quaternion:
|
|
34
|
+
return Quaternion(-self.w, -self.x, -self.y, -self.z)
|
|
35
|
+
|
|
36
|
+
def __mul__(self, o: Quaternion) -> Quaternion:
|
|
37
|
+
return Quaternion(
|
|
38
|
+
self.w * o.w - self.x * o.x - self.y * o.y - self.z * o.z,
|
|
39
|
+
self.w * o.x + self.x * o.w + self.y * o.z - self.z * o.y,
|
|
40
|
+
self.w * o.y - self.x * o.z + self.y * o.w + self.z * o.x,
|
|
41
|
+
self.w * o.z + self.x * o.y - self.y * o.x + self.z * o.w,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def conjugate(self) -> Quaternion:
|
|
45
|
+
return Quaternion(self.w, -self.x, -self.y, -self.z)
|
|
46
|
+
|
|
47
|
+
def rotate(self, v: tuple[float, float, float]) -> tuple[float, float, float]:
|
|
48
|
+
"""Rotate a phone-frame vector into the world frame."""
|
|
49
|
+
p = self * Quaternion(0.0, *v) * self.conjugate()
|
|
50
|
+
return (p.x, p.y, p.z)
|
|
51
|
+
|
|
52
|
+
def matrix(self) -> tuple[tuple[float, float, float], ...]:
|
|
53
|
+
w, x, y, z = self
|
|
54
|
+
return (
|
|
55
|
+
(1 - 2 * (y * y + z * z), 2 * (x * y - w * z), 2 * (x * z + w * y)),
|
|
56
|
+
(2 * (x * y + w * z), 1 - 2 * (x * x + z * z), 2 * (y * z - w * x)),
|
|
57
|
+
(2 * (x * z - w * y), 2 * (y * z + w * x), 1 - 2 * (x * x + y * y)),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def euler(self) -> tuple[float, float, float]:
|
|
61
|
+
"""(yaw, pitch, roll) in degrees, applied yaw about Z, then pitch about X, then roll about Y.
|
|
62
|
+
|
|
63
|
+
Yaw is heading (positive turns left), pitch tips the top of the phone up,
|
|
64
|
+
roll twists the phone around its long edge.
|
|
65
|
+
"""
|
|
66
|
+
r = self.matrix()
|
|
67
|
+
pitch = math.asin(max(-1.0, min(1.0, r[2][1])))
|
|
68
|
+
yaw = math.atan2(-r[0][1], r[1][1])
|
|
69
|
+
roll = math.atan2(-r[2][0], r[2][2])
|
|
70
|
+
return (math.degrees(yaw), math.degrees(pitch), math.degrees(roll))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_Z = (0.0, 0.0, 1.0)
|
|
74
|
+
_X = (1.0, 0.0, 0.0)
|
|
75
|
+
_Y = (0.0, 1.0, 0.0)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def from_device_angles(alpha: float, beta: float, gamma: float) -> Quaternion:
|
|
79
|
+
"""Convert browser DeviceOrientationEvent angles (degrees) to a quaternion.
|
|
80
|
+
|
|
81
|
+
Per the W3C spec the rotation is Z(alpha) then X(beta) then Y(gamma), intrinsic.
|
|
82
|
+
"""
|
|
83
|
+
return (
|
|
84
|
+
Quaternion.from_axis_angle(_Z, alpha)
|
|
85
|
+
* Quaternion.from_axis_angle(_X, beta)
|
|
86
|
+
* Quaternion.from_axis_angle(_Y, gamma)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def recentre_offset(raw: Quaternion) -> Quaternion:
|
|
91
|
+
"""The heading-only correction that makes the phone's current facing count as forward.
|
|
92
|
+
|
|
93
|
+
Facing is the direction the top of the phone points; when the phone stands
|
|
94
|
+
upright that direction is vertical, so the back of the phone is used instead.
|
|
95
|
+
"""
|
|
96
|
+
top = raw.rotate((0.0, 1.0, 0.0))
|
|
97
|
+
back = raw.rotate((0.0, 0.0, -1.0))
|
|
98
|
+
fx, fy, _ = top if math.hypot(top[0], top[1]) > 0.5 else back
|
|
99
|
+
heading = math.degrees(math.atan2(-fx, fy))
|
|
100
|
+
return Quaternion.from_axis_angle(_Z, -heading)
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""The Game-facing API: a Room that Controllers join, polled for their state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import secrets
|
|
7
|
+
import socket
|
|
8
|
+
import threading
|
|
9
|
+
from typing import Callable
|
|
10
|
+
|
|
11
|
+
from .orientation import Quaternion, recentre_offset
|
|
12
|
+
from .server import Server
|
|
13
|
+
from .tunnel import Tunnel
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Controller:
|
|
17
|
+
"""The live state of one Slot. Safe to read from any thread, every frame."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, slot: int):
|
|
20
|
+
self.slot = slot
|
|
21
|
+
self._lock = threading.Lock()
|
|
22
|
+
self._raw = Quaternion.identity()
|
|
23
|
+
self._offset = Quaternion.identity()
|
|
24
|
+
self._button = False
|
|
25
|
+
self._connected = False
|
|
26
|
+
self._has_data = False
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def orientation(self) -> Quaternion:
|
|
30
|
+
"""Rotation from phone frame to world frame (+X right, +Y towards the screen, +Z up)."""
|
|
31
|
+
with self._lock:
|
|
32
|
+
return self._offset * self._raw
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def euler(self) -> tuple[float, float, float]:
|
|
36
|
+
"""(yaw, pitch, roll) in degrees. See Quaternion.euler."""
|
|
37
|
+
return self.orientation.euler()
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def button(self) -> bool:
|
|
41
|
+
with self._lock:
|
|
42
|
+
return self._button
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def connected(self) -> bool:
|
|
46
|
+
with self._lock:
|
|
47
|
+
return self._connected
|
|
48
|
+
|
|
49
|
+
def recenter(self) -> None:
|
|
50
|
+
"""Make the direction the phone currently faces count as straight ahead."""
|
|
51
|
+
with self._lock:
|
|
52
|
+
self._offset = recentre_offset(self._raw)
|
|
53
|
+
|
|
54
|
+
def __repr__(self) -> str:
|
|
55
|
+
yaw, pitch, roll = self.euler
|
|
56
|
+
return (
|
|
57
|
+
f"Controller(slot={self.slot}, connected={self.connected}, button={self.button}, "
|
|
58
|
+
f"yaw={yaw:.0f}, pitch={pitch:.0f}, roll={roll:.0f})"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Called by the server thread.
|
|
62
|
+
|
|
63
|
+
def _update(self, raw: Quaternion, button: bool) -> None:
|
|
64
|
+
with self._lock:
|
|
65
|
+
first = not self._has_data
|
|
66
|
+
self._raw = raw
|
|
67
|
+
self._button = button
|
|
68
|
+
self._has_data = True
|
|
69
|
+
if first:
|
|
70
|
+
self.recenter()
|
|
71
|
+
|
|
72
|
+
def _set_connected(self, connected: bool) -> None:
|
|
73
|
+
with self._lock:
|
|
74
|
+
self._connected = connected
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Slots:
|
|
78
|
+
"""Hands out Slots, giving a returning Controller its old one back."""
|
|
79
|
+
|
|
80
|
+
def __init__(self):
|
|
81
|
+
self._by_id: dict[str, int] = {}
|
|
82
|
+
|
|
83
|
+
def claim(self, controller_id: str) -> int:
|
|
84
|
+
if controller_id not in self._by_id:
|
|
85
|
+
self._by_id[controller_id] = len(self._by_id)
|
|
86
|
+
return self._by_id[controller_id]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Room:
|
|
90
|
+
"""One Game's session. Creating it starts the server and the public tunnel.
|
|
91
|
+
|
|
92
|
+
>>> room = pocketwand.Room()
|
|
93
|
+
>>> room.controller(0).euler
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(self, *, tunnel: bool = True, port: int = 0, show_qr: bool = True):
|
|
97
|
+
self.code = secrets.token_urlsafe(6)
|
|
98
|
+
self.on_join: Callable[[Controller], None] | None = None
|
|
99
|
+
self.on_leave: Callable[[Controller], None] | None = None
|
|
100
|
+
|
|
101
|
+
self._controllers: dict[int, Controller] = {}
|
|
102
|
+
self._controllers_lock = threading.Lock()
|
|
103
|
+
self._slots = Slots()
|
|
104
|
+
|
|
105
|
+
self.port = port or _free_port()
|
|
106
|
+
self._loop = asyncio.new_event_loop()
|
|
107
|
+
self._server = Server(self, self._loop, host="127.0.0.1" if tunnel else "0.0.0.0", port=self.port)
|
|
108
|
+
ready = threading.Event()
|
|
109
|
+
self._thread = threading.Thread(target=self._run, args=(ready,), daemon=True, name="pocketwand-server")
|
|
110
|
+
self._thread.start()
|
|
111
|
+
ready.wait()
|
|
112
|
+
|
|
113
|
+
if tunnel:
|
|
114
|
+
self._tunnel: Tunnel | None = Tunnel(self.port)
|
|
115
|
+
base = self._tunnel.url
|
|
116
|
+
else:
|
|
117
|
+
self._tunnel = None
|
|
118
|
+
base = f"http://{_lan_ip()}:{self.port}"
|
|
119
|
+
self.join_url = f"{base}/?room={self.code}"
|
|
120
|
+
|
|
121
|
+
if show_qr:
|
|
122
|
+
self.print_qr()
|
|
123
|
+
|
|
124
|
+
def controller(self, slot: int = 0) -> Controller:
|
|
125
|
+
"""The Controller in `slot`. Never None: an empty Slot reads as disconnected."""
|
|
126
|
+
with self._controllers_lock:
|
|
127
|
+
if slot not in self._controllers:
|
|
128
|
+
self._controllers[slot] = Controller(slot)
|
|
129
|
+
return self._controllers[slot]
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def controllers(self) -> list[Controller]:
|
|
133
|
+
"""Every Controller that is currently connected, ordered by Slot."""
|
|
134
|
+
with self._controllers_lock:
|
|
135
|
+
return [c for _, c in sorted(self._controllers.items()) if c.connected]
|
|
136
|
+
|
|
137
|
+
def print_qr(self) -> None:
|
|
138
|
+
import qrcode
|
|
139
|
+
|
|
140
|
+
qr = qrcode.QRCode(border=1)
|
|
141
|
+
qr.add_data(self.join_url)
|
|
142
|
+
qr.print_ascii(invert=True)
|
|
143
|
+
print(f"Scan with your phone, or open: {self.join_url}")
|
|
144
|
+
|
|
145
|
+
def close(self) -> None:
|
|
146
|
+
if self._tunnel:
|
|
147
|
+
self._tunnel.close()
|
|
148
|
+
if self._loop.is_running():
|
|
149
|
+
try:
|
|
150
|
+
asyncio.run_coroutine_threadsafe(self._server.stop(), self._loop).result(timeout=3)
|
|
151
|
+
except Exception:
|
|
152
|
+
pass
|
|
153
|
+
self._loop.call_soon_threadsafe(self._loop.stop)
|
|
154
|
+
|
|
155
|
+
def __enter__(self) -> Room:
|
|
156
|
+
return self
|
|
157
|
+
|
|
158
|
+
def __exit__(self, *exc) -> None:
|
|
159
|
+
self.close()
|
|
160
|
+
|
|
161
|
+
def _run(self, ready: threading.Event) -> None:
|
|
162
|
+
asyncio.set_event_loop(self._loop)
|
|
163
|
+
self._loop.run_until_complete(self._server.start())
|
|
164
|
+
ready.set()
|
|
165
|
+
self._loop.run_forever()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _free_port() -> int:
|
|
169
|
+
with socket.socket() as s:
|
|
170
|
+
s.bind(("127.0.0.1", 0))
|
|
171
|
+
return s.getsockname()[1]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _lan_ip() -> str:
|
|
175
|
+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
|
176
|
+
try:
|
|
177
|
+
s.connect(("10.255.255.255", 1))
|
|
178
|
+
return s.getsockname()[0]
|
|
179
|
+
except OSError:
|
|
180
|
+
return "127.0.0.1"
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Serves the Controller page and receives Controller messages over a WebSocket."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from importlib.resources import files
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from aiohttp import WSMsgType, web
|
|
11
|
+
|
|
12
|
+
from .orientation import from_device_angles
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from .room import Room
|
|
16
|
+
|
|
17
|
+
PAGE = files("pocketwand").joinpath("static/controller.html")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Server:
|
|
21
|
+
def __init__(self, room: Room, loop: asyncio.AbstractEventLoop, host: str, port: int):
|
|
22
|
+
self.room = room
|
|
23
|
+
self.loop = loop
|
|
24
|
+
self.host = host
|
|
25
|
+
self.port = port
|
|
26
|
+
self._sockets: dict[int, web.WebSocketResponse] = {}
|
|
27
|
+
|
|
28
|
+
async def start(self) -> None:
|
|
29
|
+
app = web.Application()
|
|
30
|
+
app.router.add_get("/", self._page)
|
|
31
|
+
app.router.add_get("/ws", self._ws)
|
|
32
|
+
self._runner = web.AppRunner(app, access_log=None)
|
|
33
|
+
await self._runner.setup()
|
|
34
|
+
await web.TCPSite(self._runner, self.host, self.port).start()
|
|
35
|
+
|
|
36
|
+
async def stop(self) -> None:
|
|
37
|
+
for ws in list(self._sockets.values()):
|
|
38
|
+
await ws.close()
|
|
39
|
+
await self._runner.cleanup()
|
|
40
|
+
|
|
41
|
+
async def _page(self, request: web.Request) -> web.Response:
|
|
42
|
+
return web.Response(
|
|
43
|
+
text=PAGE.read_text(encoding="utf-8"),
|
|
44
|
+
content_type="text/html",
|
|
45
|
+
headers={"Cache-Control": "no-store"},
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
async def _ws(self, request: web.Request) -> web.StreamResponse:
|
|
49
|
+
if request.query.get("room") != self.room.code:
|
|
50
|
+
raise web.HTTPForbidden(text="Wrong or missing Room code")
|
|
51
|
+
controller_id = request.query.get("id", "")
|
|
52
|
+
if not controller_id:
|
|
53
|
+
raise web.HTTPBadRequest(text="Missing controller id")
|
|
54
|
+
|
|
55
|
+
ws = web.WebSocketResponse(heartbeat=5)
|
|
56
|
+
await ws.prepare(request)
|
|
57
|
+
|
|
58
|
+
slot = self.room._slots.claim(controller_id)
|
|
59
|
+
controller = self.room.controller(slot)
|
|
60
|
+
|
|
61
|
+
# The same phone reconnecting (e.g. a reloaded tab) replaces its old connection.
|
|
62
|
+
old = self._sockets.get(slot)
|
|
63
|
+
self._sockets[slot] = ws
|
|
64
|
+
if old is not None:
|
|
65
|
+
await old.close()
|
|
66
|
+
|
|
67
|
+
controller._set_connected(True)
|
|
68
|
+
await ws.send_json({"t": "slot", "slot": slot})
|
|
69
|
+
_call(self.room.on_join, controller)
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
async for msg in ws:
|
|
73
|
+
if msg.type != WSMsgType.TEXT:
|
|
74
|
+
continue
|
|
75
|
+
try:
|
|
76
|
+
data = json.loads(msg.data)
|
|
77
|
+
if data.get("t") == "recentre":
|
|
78
|
+
controller.recenter()
|
|
79
|
+
elif "a" in data:
|
|
80
|
+
raw = from_device_angles(float(data["a"] or 0), float(data["b"] or 0), float(data["g"] or 0))
|
|
81
|
+
controller._update(raw, bool(data.get("p")))
|
|
82
|
+
except (ValueError, TypeError, KeyError, AttributeError):
|
|
83
|
+
continue # ignore malformed messages
|
|
84
|
+
finally:
|
|
85
|
+
if self._sockets.get(slot) is ws:
|
|
86
|
+
del self._sockets[slot]
|
|
87
|
+
controller._set_connected(False)
|
|
88
|
+
_call(self.room.on_leave, controller)
|
|
89
|
+
return ws
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _call(callback, controller) -> None:
|
|
93
|
+
if callback is None:
|
|
94
|
+
return
|
|
95
|
+
try:
|
|
96
|
+
callback(controller)
|
|
97
|
+
except Exception as e: # a buggy Game callback must not kill the server
|
|
98
|
+
print(f"pocketwand: callback raised {e!r}")
|
|
@@ -0,0 +1,130 @@
|
|
|
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">
|
|
6
|
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
7
|
+
<title>PocketWand Controller</title>
|
|
8
|
+
<style>
|
|
9
|
+
:root { --bg: #0e1116; --fg: #e8edf3; --dim: #8a96a6; --accent: #4cc9f0; --hot: #f72585; }
|
|
10
|
+
* { box-sizing: border-box; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
|
|
11
|
+
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--fg);
|
|
12
|
+
font: 16px/1.4 -apple-system, system-ui, sans-serif; touch-action: none; overflow: hidden; }
|
|
13
|
+
body { display: flex; flex-direction: column; padding: 16px; gap: 12px; }
|
|
14
|
+
#status { font-size: 14px; color: var(--dim); display: flex; justify-content: space-between; }
|
|
15
|
+
#status b { color: var(--fg); }
|
|
16
|
+
.screen { flex: 1; display: none; flex-direction: column; gap: 12px; }
|
|
17
|
+
.screen.on { display: flex; }
|
|
18
|
+
button { font: inherit; border: 0; border-radius: 16px; color: var(--bg); background: var(--accent); padding: 18px; }
|
|
19
|
+
#start button { margin: auto 0; font-size: 22px; padding: 28px; }
|
|
20
|
+
#trigger { flex: 1; background: #1c2230; color: var(--fg); font-size: 22px; }
|
|
21
|
+
#trigger.down { background: var(--hot); }
|
|
22
|
+
#recentre { background: #2b3446; color: var(--fg); }
|
|
23
|
+
#angles { font: 13px ui-monospace, monospace; color: var(--dim); text-align: center; }
|
|
24
|
+
#msg { color: var(--dim); text-align: center; margin: auto 0; }
|
|
25
|
+
</style>
|
|
26
|
+
</head>
|
|
27
|
+
<body>
|
|
28
|
+
<div id="status"><span id="conn">Not connected</span><span id="slot"></span></div>
|
|
29
|
+
|
|
30
|
+
<div id="start" class="screen on">
|
|
31
|
+
<p id="msg">Tap below, then allow motion access.</p>
|
|
32
|
+
<button id="enable">Enable motion</button>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<div id="pad" class="screen">
|
|
36
|
+
<button id="trigger">Hold</button>
|
|
37
|
+
<div id="angles"></div>
|
|
38
|
+
<button id="recentre">Recentre (point at your screen)</button>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<script>
|
|
42
|
+
const $ = (id) => document.getElementById(id);
|
|
43
|
+
const room = new URLSearchParams(location.search).get("room");
|
|
44
|
+
|
|
45
|
+
// A stable random id lets this phone reclaim its Slot after reconnecting.
|
|
46
|
+
let id;
|
|
47
|
+
try { id = localStorage.getItem("pocketwand-id"); } catch {}
|
|
48
|
+
if (!id) {
|
|
49
|
+
id = Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
50
|
+
try { localStorage.setItem("pocketwand-id", id); } catch {}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const state = { a: null, b: null, g: null, p: 0 };
|
|
54
|
+
let ws = null, failures = 0, lastSent = "", lastSentAt = 0;
|
|
55
|
+
|
|
56
|
+
if (!room) {
|
|
57
|
+
$("msg").textContent = "Open this page by scanning the QR code shown by the game.";
|
|
58
|
+
$("enable").style.display = "none";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
$("enable").onclick = async () => {
|
|
62
|
+
// iOS only hands out sensor data after this permission call, made directly from a tap.
|
|
63
|
+
if (typeof DeviceOrientationEvent !== "undefined" && typeof DeviceOrientationEvent.requestPermission === "function") {
|
|
64
|
+
try {
|
|
65
|
+
if (await DeviceOrientationEvent.requestPermission() !== "granted") {
|
|
66
|
+
$("msg").textContent = "Motion access was denied. Close this tab, reopen the link and tap Allow.";
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
} catch (e) {
|
|
70
|
+
$("msg").textContent = "Couldn't ask for motion access: " + e.message;
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
window.addEventListener("deviceorientation", (e) => { state.a = e.alpha; state.b = e.beta; state.g = e.gamma; });
|
|
75
|
+
$("start").classList.remove("on");
|
|
76
|
+
$("pad").classList.add("on");
|
|
77
|
+
keepAwake();
|
|
78
|
+
connect();
|
|
79
|
+
requestAnimationFrame(send);
|
|
80
|
+
setTimeout(() => {
|
|
81
|
+
if (state.b === null) $("angles").textContent = "No motion data. This browser or device may not have motion sensors.";
|
|
82
|
+
}, 2000);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
function connect() {
|
|
86
|
+
const proto = location.protocol === "https:" ? "wss" : "ws";
|
|
87
|
+
ws = new WebSocket(`${proto}://${location.host}/ws?room=${encodeURIComponent(room)}&id=${encodeURIComponent(id)}`);
|
|
88
|
+
$("conn").textContent = "Connecting…";
|
|
89
|
+
ws.onopen = () => { failures = 0; $("conn").innerHTML = "<b>Connected</b>"; };
|
|
90
|
+
ws.onmessage = (e) => {
|
|
91
|
+
const m = JSON.parse(e.data);
|
|
92
|
+
if (m.t === "slot") $("slot").textContent = "Slot " + m.slot;
|
|
93
|
+
};
|
|
94
|
+
ws.onclose = () => {
|
|
95
|
+
failures++;
|
|
96
|
+
$("conn").textContent = failures > 5 ? "Can't reach the game. The link may be old; scan the new QR code." : "Reconnecting…";
|
|
97
|
+
setTimeout(connect, Math.min(1000 * failures, 5000));
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function send() {
|
|
102
|
+
requestAnimationFrame(send);
|
|
103
|
+
if (!ws || ws.readyState !== WebSocket.OPEN || state.b === null) return;
|
|
104
|
+
// Drop frames rather than queue them: stale orientation is worse than missing orientation.
|
|
105
|
+
if (ws.bufferedAmount > 1024) return;
|
|
106
|
+
const msg = JSON.stringify(state);
|
|
107
|
+
const now = performance.now();
|
|
108
|
+
if (msg === lastSent && now - lastSentAt < 250) return;
|
|
109
|
+
ws.send(msg);
|
|
110
|
+
lastSent = msg; lastSentAt = now;
|
|
111
|
+
$("angles").textContent = `α ${state.a?.toFixed(0)} β ${state.b?.toFixed(0)} γ ${state.g?.toFixed(0)}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const trigger = $("trigger");
|
|
115
|
+
const press = (down) => (e) => { e.preventDefault(); state.p = down ? 1 : 0; trigger.classList.toggle("down", down); };
|
|
116
|
+
trigger.addEventListener("pointerdown", press(true));
|
|
117
|
+
trigger.addEventListener("pointerup", press(false));
|
|
118
|
+
trigger.addEventListener("pointercancel", press(false));
|
|
119
|
+
trigger.addEventListener("pointerleave", press(false));
|
|
120
|
+
|
|
121
|
+
$("recentre").onclick = () => { if (ws && ws.readyState === WebSocket.OPEN) ws.send('{"t":"recentre"}'); };
|
|
122
|
+
|
|
123
|
+
let wakeLock = null;
|
|
124
|
+
async function keepAwake() {
|
|
125
|
+
try { wakeLock = await navigator.wakeLock?.request("screen"); } catch {}
|
|
126
|
+
}
|
|
127
|
+
document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") keepAwake(); });
|
|
128
|
+
</script>
|
|
129
|
+
</body>
|
|
130
|
+
</html>
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Gives the local server a public HTTPS address with a Cloudflare quick tunnel.
|
|
2
|
+
|
|
3
|
+
A tunnel rather than a hosted relay or a LAN-only server: iOS needs HTTPS for motion
|
|
4
|
+
sensors, campus Wi-Fi blocks device-to-device traffic, and nobody has to host anything.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import atexit
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
import urllib.request
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
URL_PATTERN = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Tunnel:
|
|
23
|
+
def __init__(self, port: int, timeout: float = 30.0):
|
|
24
|
+
exe = _cloudflared()
|
|
25
|
+
print("pocketwand: opening a public tunnel (a few seconds)...")
|
|
26
|
+
self._process = subprocess.Popen(
|
|
27
|
+
[exe, "tunnel", "--no-autoupdate", "--url", f"http://127.0.0.1:{port}"],
|
|
28
|
+
stdout=subprocess.DEVNULL,
|
|
29
|
+
stderr=subprocess.PIPE,
|
|
30
|
+
text=True,
|
|
31
|
+
)
|
|
32
|
+
atexit.register(self.close)
|
|
33
|
+
|
|
34
|
+
found = threading.Event()
|
|
35
|
+
self.url = ""
|
|
36
|
+
|
|
37
|
+
def read_log():
|
|
38
|
+
# Keep draining stderr for the tunnel's whole life, or cloudflared blocks once the pipe fills.
|
|
39
|
+
for line in self._process.stderr:
|
|
40
|
+
if not self.url and (m := URL_PATTERN.search(line)):
|
|
41
|
+
self.url = m.group(0)
|
|
42
|
+
found.set()
|
|
43
|
+
|
|
44
|
+
threading.Thread(target=read_log, daemon=True, name="pocketwand-tunnel-log").start()
|
|
45
|
+
if not found.wait(timeout):
|
|
46
|
+
self.close()
|
|
47
|
+
raise RuntimeError("cloudflared did not report a tunnel URL; check your internet connection")
|
|
48
|
+
_wait_until_reachable(self.url, timeout)
|
|
49
|
+
|
|
50
|
+
def close(self) -> None:
|
|
51
|
+
if self._process.poll() is None:
|
|
52
|
+
self._process.terminate()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _cloudflared() -> str:
|
|
56
|
+
"""A cloudflared already on PATH, otherwise one downloaded by pycloudflared on first use."""
|
|
57
|
+
if exe := shutil.which("cloudflared"):
|
|
58
|
+
return exe
|
|
59
|
+
from pycloudflared.util import download, get_info
|
|
60
|
+
|
|
61
|
+
info = get_info()
|
|
62
|
+
if not Path(info.executable).exists():
|
|
63
|
+
download(info)
|
|
64
|
+
return info.executable
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _wait_until_reachable(url: str, timeout: float) -> None:
|
|
68
|
+
"""New quick-tunnel hostnames take a few seconds to exist; don't hand out a dead link.
|
|
69
|
+
|
|
70
|
+
Only ask Cloudflare's own DNS. Looking the name up through the local resolver
|
|
71
|
+
before it exists makes that resolver cache "no such host" for 30 minutes (the
|
|
72
|
+
trycloudflare.com negative TTL), breaking the link for every device on the network.
|
|
73
|
+
"""
|
|
74
|
+
host = url.removeprefix("https://")
|
|
75
|
+
query = f"https://1.1.1.1/dns-query?name={host}&type=A"
|
|
76
|
+
deadline = time.monotonic() + timeout
|
|
77
|
+
while time.monotonic() < deadline:
|
|
78
|
+
try:
|
|
79
|
+
req = urllib.request.Request(query, headers={"accept": "application/dns-json"})
|
|
80
|
+
with urllib.request.urlopen(req, timeout=3) as resp:
|
|
81
|
+
if json.load(resp).get("Answer"):
|
|
82
|
+
return
|
|
83
|
+
except Exception:
|
|
84
|
+
pass
|
|
85
|
+
time.sleep(1)
|
|
86
|
+
print("pocketwand: tunnel is slow to come up; the link may take a moment to work")
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pocketwand
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Turn any phone into a motion controller, streamed through the browser.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/agasthyako/Pocket-Wand
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: aiohttp>=3.9
|
|
11
|
+
Requires-Dist: pycloudflared>=0.2
|
|
12
|
+
Requires-Dist: qrcode>=7.4
|
|
13
|
+
Provides-Extra: demo
|
|
14
|
+
Requires-Dist: pygame-ce>=2.4; extra == "demo"
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# PocketWand
|
|
20
|
+
|
|
21
|
+
Turn any phone into a motion controller for your Python program. The phone streams its rotation through its web browser, so there's no app to install, and it works on iPhone and Android, on any network (campus Wi-Fi included).
|
|
22
|
+
|
|
23
|
+
## Try the demo
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
pip install "pocketwand[demo] @ git+https://github.com/agasthyako/Pocket-Wand"
|
|
27
|
+
pocketwand demo
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
1. A window opens with a QR code (it takes a few seconds to get a public link).
|
|
31
|
+
2. Scan it with your phone's camera and open the link.
|
|
32
|
+
3. Tap **Enable motion** and allow access.
|
|
33
|
+
4. Point your phone at the screen and move it. The phone on screen copies it.
|
|
34
|
+
|
|
35
|
+
Press **R** (or the Recentre button on the phone) to make "pointing at the screen" count as straight ahead again.
|
|
36
|
+
|
|
37
|
+
`pocketwand print` does the same without a window and prints the Controller state to the terminal.
|
|
38
|
+
|
|
39
|
+
## Use it in your own Game
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
import pocketwand
|
|
43
|
+
|
|
44
|
+
room = pocketwand.Room() # starts the server and tunnel, prints a QR code
|
|
45
|
+
room.on_join = lambda c: print("joined slot", c.slot)
|
|
46
|
+
|
|
47
|
+
while True:
|
|
48
|
+
c = room.controller(0) # never None; an empty Slot reads as disconnected
|
|
49
|
+
if c.connected:
|
|
50
|
+
c.orientation # Quaternion(w, x, y, z)
|
|
51
|
+
c.orientation.matrix() # 3x3 rotation matrix
|
|
52
|
+
yaw, pitch, roll = c.euler # degrees
|
|
53
|
+
c.button # True while the on-screen button is held
|
|
54
|
+
# c.recenter() makes the current facing count as straight ahead
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**Frames.** Orientation rotates the phone's frame (+X right edge, +Y top of the phone, +Z out of the screen) into the world frame (+X right, +Y towards your screen, +Z up). Recentring only changes heading, so tilt always matches gravity.
|
|
58
|
+
|
|
59
|
+
**Several phones.** Every phone that opens the link gets its own Slot (`room.controller(1)`, …). A phone that reconnects gets its old Slot back. `room.controllers` lists the connected ones.
|
|
60
|
+
|
|
61
|
+
State is updated on a background thread; reading it from your game loop every frame is safe and cheap.
|
|
62
|
+
|
|
63
|
+
## How it works
|
|
64
|
+
|
|
65
|
+
`Room()` runs a small web server on your computer and opens a free [Cloudflare quick tunnel](https://try.cloudflare.com) to it (the `cloudflared` program is downloaded automatically on first run). The phone opens the page over HTTPS, which iOS requires before a page can read motion sensors, and streams its orientation back over a WebSocket. Both sides only make outgoing connections, so firewalls and campus client isolation don't get in the way.
|
|
66
|
+
|
|
67
|
+
Needs Python 3.10+ and an internet connection.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/pocketwand/__init__.py
|
|
5
|
+
src/pocketwand/cli.py
|
|
6
|
+
src/pocketwand/demo.py
|
|
7
|
+
src/pocketwand/orientation.py
|
|
8
|
+
src/pocketwand/room.py
|
|
9
|
+
src/pocketwand/server.py
|
|
10
|
+
src/pocketwand/tunnel.py
|
|
11
|
+
src/pocketwand.egg-info/PKG-INFO
|
|
12
|
+
src/pocketwand.egg-info/SOURCES.txt
|
|
13
|
+
src/pocketwand.egg-info/dependency_links.txt
|
|
14
|
+
src/pocketwand.egg-info/entry_points.txt
|
|
15
|
+
src/pocketwand.egg-info/requires.txt
|
|
16
|
+
src/pocketwand.egg-info/top_level.txt
|
|
17
|
+
src/pocketwand/static/controller.html
|
|
18
|
+
tests/test_orientation.py
|
|
19
|
+
tests/test_room.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pocketwand
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import math
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from pocketwand.orientation import Quaternion, from_device_angles, recentre_offset
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def close(a, b, tol=1e-6):
|
|
9
|
+
return all(abs(x - y) < tol for x, y in zip(a, b))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def same_rotation(q1, q2, tol=1e-6):
|
|
13
|
+
# q and -q are the same rotation
|
|
14
|
+
return close(tuple(q1), tuple(q2), tol) or close(tuple(q1), tuple(-q2), tol)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_identity_when_phone_flat_facing_north():
|
|
18
|
+
assert same_rotation(from_device_angles(0, 0, 0), Quaternion.identity())
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.mark.parametrize(
|
|
22
|
+
"alpha,beta,gamma",
|
|
23
|
+
[(30, 0, 0), (0, 45, 0), (0, 0, -60), (120, 30, -20), (350, -70, 85)],
|
|
24
|
+
)
|
|
25
|
+
def test_euler_round_trips_device_angles(alpha, beta, gamma):
|
|
26
|
+
yaw, pitch, roll = from_device_angles(alpha, beta, gamma).euler()
|
|
27
|
+
assert yaw == pytest.approx(alpha if alpha <= 180 else alpha - 360)
|
|
28
|
+
assert pitch == pytest.approx(beta)
|
|
29
|
+
assert roll == pytest.approx(gamma)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_rotating_vector_matches_right_hand_rule():
|
|
33
|
+
# 90 degrees about Z (alpha) turns the phone's top (+Y) to point west (-X)
|
|
34
|
+
q = from_device_angles(90, 0, 0)
|
|
35
|
+
assert close(q.rotate((0, 1, 0)), (-1, 0, 0))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_recentre_makes_current_heading_forward():
|
|
39
|
+
raw = from_device_angles(73, 20, 10)
|
|
40
|
+
offset = recentre_offset(raw)
|
|
41
|
+
yaw, pitch, roll = (offset * raw).euler()
|
|
42
|
+
assert yaw == pytest.approx(0, abs=1e-6)
|
|
43
|
+
# recentring only removes heading; tilt stays true to gravity
|
|
44
|
+
assert pitch == pytest.approx(20)
|
|
45
|
+
assert roll == pytest.approx(10)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_recentre_with_phone_upright_uses_back_of_phone():
|
|
49
|
+
# phone standing up (top pointing at the sky), screen facing the user, turned 40 degrees
|
|
50
|
+
raw = from_device_angles(40, 90, 0)
|
|
51
|
+
forward = (recentre_offset(raw) * raw).rotate((0, 0, -1))
|
|
52
|
+
assert close(forward, (0, 1, 0), 1e-6)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
import aiohttp
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from pocketwand import Room
|
|
8
|
+
from pocketwand.room import Slots
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_returning_controller_reclaims_its_slot():
|
|
12
|
+
slots = Slots()
|
|
13
|
+
assert slots.claim("phone-a") == 0
|
|
14
|
+
assert slots.claim("phone-b") == 1
|
|
15
|
+
assert slots.claim("phone-a") == 0
|
|
16
|
+
assert slots.claim("phone-c") == 2
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_empty_slot_reads_as_disconnected():
|
|
20
|
+
room = Room(tunnel=False, show_qr=False)
|
|
21
|
+
try:
|
|
22
|
+
c = room.controller(3)
|
|
23
|
+
assert not c.connected
|
|
24
|
+
assert c.euler == pytest.approx((0, 0, 0))
|
|
25
|
+
finally:
|
|
26
|
+
room.close()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture
|
|
30
|
+
def room():
|
|
31
|
+
r = Room(tunnel=False, show_qr=False)
|
|
32
|
+
yield r
|
|
33
|
+
r.close()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def ws_url(room, code, controller_id="phone-a"):
|
|
37
|
+
return f"ws://127.0.0.1:{room.port}/ws?room={code}&id={controller_id}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def run(coro):
|
|
41
|
+
return asyncio.run(coro)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_wrong_room_code_is_refused(room):
|
|
45
|
+
async def go():
|
|
46
|
+
async with aiohttp.ClientSession() as s:
|
|
47
|
+
with pytest.raises(aiohttp.WSServerHandshakeError):
|
|
48
|
+
await s.ws_connect(ws_url(room, "wrong"))
|
|
49
|
+
|
|
50
|
+
run(go())
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_controller_streams_orientation_and_button(room):
|
|
54
|
+
joined = []
|
|
55
|
+
room.on_join = joined.append
|
|
56
|
+
|
|
57
|
+
async def go():
|
|
58
|
+
async with aiohttp.ClientSession() as s:
|
|
59
|
+
async with s.ws_connect(ws_url(room, room.code)) as ws:
|
|
60
|
+
assert json.loads((await ws.receive()).data) == {"t": "slot", "slot": 0}
|
|
61
|
+
await ws.send_json({"a": 10, "b": 30, "g": -15, "p": 0}) # first reading recentres
|
|
62
|
+
await ws.send_json({"a": 40, "b": 30, "g": -15, "p": 1})
|
|
63
|
+
await asyncio.sleep(0.2)
|
|
64
|
+
c = room.controller(0)
|
|
65
|
+
assert c.connected and c.button
|
|
66
|
+
yaw, pitch, roll = c.euler
|
|
67
|
+
assert yaw == pytest.approx(30, abs=1e-6)
|
|
68
|
+
assert pitch == pytest.approx(30, abs=1e-6)
|
|
69
|
+
assert roll == pytest.approx(-15, abs=1e-6)
|
|
70
|
+
await asyncio.sleep(0.2)
|
|
71
|
+
|
|
72
|
+
run(go())
|
|
73
|
+
assert joined == [room.controller(0)]
|
|
74
|
+
assert not room.controller(0).connected
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_reloaded_tab_replaces_old_connection_in_same_slot(room):
|
|
78
|
+
async def go():
|
|
79
|
+
async with aiohttp.ClientSession() as s:
|
|
80
|
+
old = await s.ws_connect(ws_url(room, room.code))
|
|
81
|
+
await old.receive()
|
|
82
|
+
new = await s.ws_connect(ws_url(room, room.code))
|
|
83
|
+
assert json.loads((await new.receive()).data)["slot"] == 0
|
|
84
|
+
await asyncio.sleep(0.2)
|
|
85
|
+
assert room.controller(0).connected
|
|
86
|
+
assert len(room.controllers) == 1
|
|
87
|
+
await new.close()
|
|
88
|
+
await old.close()
|
|
89
|
+
|
|
90
|
+
run(go())
|