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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,7 @@
1
+ """PocketWand: turn any phone into a motion controller, streamed through the browser."""
2
+
3
+ from .orientation import Quaternion
4
+ from .room import Controller, Room
5
+
6
+ __all__ = ["Controller", "Quaternion", "Room"]
7
+ __version__ = "0.1.0"
@@ -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,2 @@
1
+ [console_scripts]
2
+ pocketwand = pocketwand.cli:main
@@ -0,0 +1,9 @@
1
+ aiohttp>=3.9
2
+ pycloudflared>=0.2
3
+ qrcode>=7.4
4
+
5
+ [demo]
6
+ pygame-ce>=2.4
7
+
8
+ [dev]
9
+ pytest>=8
@@ -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())