wiimote-spotlight 0.1.0__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.
- wiimote_spotlight-0.1.0/PKG-INFO +26 -0
- wiimote_spotlight-0.1.0/README.md +14 -0
- wiimote_spotlight-0.1.0/pyproject.toml +25 -0
- wiimote_spotlight-0.1.0/pyproject.toml.orig +26 -0
- wiimote_spotlight-0.1.0/src/wiimote_spotlight/__init__.py +1 -0
- wiimote_spotlight-0.1.0/src/wiimote_spotlight/args.py +17 -0
- wiimote_spotlight-0.1.0/src/wiimote_spotlight/main.py +257 -0
- wiimote_spotlight-0.1.0/src/wiimote_spotlight/monitors.py +31 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: wiimote-spotlight
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Author: Daniel Raniz Raneland
|
|
6
|
+
Author-email: Daniel Raniz Raneland <raniz@factor10.com>
|
|
7
|
+
Requires-Dist: evdev>=2.0.0
|
|
8
|
+
Requires-Dist: pycairo>=1.29.2
|
|
9
|
+
Requires-Dist: pygobject>=3.58.0
|
|
10
|
+
Requires-Python: >=3.13
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# Wiimote spotlight
|
|
14
|
+
|
|
15
|
+
I simple Python application that uses a Wiimote to control a spotlight overlay on KDE/Plasma on Wayland.
|
|
16
|
+
|
|
17
|
+
# Installation
|
|
18
|
+
Either install from a checked out copy of this repository, or point [`uv`](https://docs.astral.sh/uv/) to the git repository:
|
|
19
|
+
|
|
20
|
+
```shell
|
|
21
|
+
$ uv install tool .
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```shell
|
|
25
|
+
$ uv install tool https://codeberg.org/raniz/wiimote-spotlight
|
|
26
|
+
```
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Wiimote spotlight
|
|
2
|
+
|
|
3
|
+
I simple Python application that uses a Wiimote to control a spotlight overlay on KDE/Plasma on Wayland.
|
|
4
|
+
|
|
5
|
+
# Installation
|
|
6
|
+
Either install from a checked out copy of this repository, or point [`uv`](https://docs.astral.sh/uv/) to the git repository:
|
|
7
|
+
|
|
8
|
+
```shell
|
|
9
|
+
$ uv install tool .
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```shell
|
|
13
|
+
$ uv install tool https://codeberg.org/raniz/wiimote-spotlight
|
|
14
|
+
```
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "wiimote-spotlight"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Add your description here"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.13"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"evdev>=2.0.0",
|
|
9
|
+
"pycairo>=1.29.2",
|
|
10
|
+
"pygobject>=3.58.0",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[[project.authors]]
|
|
14
|
+
name = "Daniel Raniz Raneland"
|
|
15
|
+
email = "raniz@factor10.com"
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
wiimote-spotlight = "wiimote_spotlight:main"
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["uv_build>=0.12.23,<0.13.0"]
|
|
22
|
+
build-backend = "uv_build"
|
|
23
|
+
|
|
24
|
+
[dependency-groups]
|
|
25
|
+
dev = ["pytest>=9.1.1"]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "wiimote-spotlight"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Add your description here"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Daniel Raniz Raneland", email = "raniz@factor10.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.13"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"evdev>=2.0.0",
|
|
12
|
+
"pycairo>=1.29.2",
|
|
13
|
+
"pygobject>=3.58.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
wiimote-spotlight = "wiimote_spotlight:main"
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["uv_build>=0.12.23,<0.13.0"]
|
|
21
|
+
build-backend = "uv_build"
|
|
22
|
+
|
|
23
|
+
[dependency-groups]
|
|
24
|
+
dev = [
|
|
25
|
+
"pytest>=9.1.1",
|
|
26
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .main import main
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def parse_args(argv=None):
|
|
5
|
+
parser = argparse.ArgumentParser(prog="wiimote-spotlight")
|
|
6
|
+
parser.add_argument("-r", "--radius", type=int, default=180,
|
|
7
|
+
help="spotlight radius in logical pixels")
|
|
8
|
+
parser.add_argument("-m", "--monitor",
|
|
9
|
+
help="screen to show the spotlight on, by index or "
|
|
10
|
+
"connector name (see --list-monitors); defaults "
|
|
11
|
+
"to the external screen when exactly two are "
|
|
12
|
+
"connected, otherwise eDP-1")
|
|
13
|
+
parser.add_argument("-s", "--sensitivity", type=float, default=2e-3,
|
|
14
|
+
help="spotlight pixels per raw gyro unit per report")
|
|
15
|
+
parser.add_argument("-l", "--list-monitors", action="store_true",
|
|
16
|
+
help="list the available screens and exit")
|
|
17
|
+
return parser.parse_args(argv)
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
def main():
|
|
4
|
+
"""
|
|
5
|
+
wiimote-spotlight: a Logitech-Spotlight-style highlight driven directly by a
|
|
6
|
+
Wii Remote with MotionPlus, on KDE Plasma / Wayland. No mouse, no Projecteur.
|
|
7
|
+
|
|
8
|
+
It draws its own click-through overlay (wlr-layer-shell, overlay layer, which
|
|
9
|
+
KWin supports) and moves the spotlight with the gyro. The real mouse cursor is
|
|
10
|
+
never touched.
|
|
11
|
+
|
|
12
|
+
Hold B ......... show spotlight (starts at screen centre) and steer it
|
|
13
|
+
Release B ...... hide spotlight
|
|
14
|
+
A, +, d-pad R .. next slide (Right arrow)
|
|
15
|
+
-, d-pad L ..... previous slide (Left arrow)
|
|
16
|
+
2 .............. Escape
|
|
17
|
+
Home ........... recalibrate gyro (lay the remote still for ~2 s)
|
|
18
|
+
|
|
19
|
+
Keep the remote still for ~2 s after starting (gyro calibration).
|
|
20
|
+
|
|
21
|
+
Needs: gtk4-layer-shell + its GObject-introspection data, PyGObject, pycairo,
|
|
22
|
+
python-evdev, the uinput module, and access to the Wiimote's /dev/input nodes
|
|
23
|
+
and /dev/uinput WITHOUT sudo (a GUI app can't run as root on Wayland), so
|
|
24
|
+
install the udev rule from the notes.
|
|
25
|
+
"""
|
|
26
|
+
from .args import parse_args
|
|
27
|
+
from .monitors import format_monitors, resolve_monitor
|
|
28
|
+
args = parse_args()
|
|
29
|
+
|
|
30
|
+
# gtk4-layer-shell must be loaded before GTK / libwayland-client.
|
|
31
|
+
from ctypes import CDLL
|
|
32
|
+
for _lib in ("libgtk4-layer-shell.so", "libgtk4-layer-shell.so.0"):
|
|
33
|
+
try:
|
|
34
|
+
CDLL(_lib)
|
|
35
|
+
break
|
|
36
|
+
except OSError:
|
|
37
|
+
pass
|
|
38
|
+
else:
|
|
39
|
+
raise SystemExit("gtk4-layer-shell not found; install it first.")
|
|
40
|
+
|
|
41
|
+
import math
|
|
42
|
+
import cairo
|
|
43
|
+
import evdev
|
|
44
|
+
from evdev import ecodes as e, UInput
|
|
45
|
+
import gi
|
|
46
|
+
gi.require_version("Gtk", "4.0")
|
|
47
|
+
gi.require_version("Gdk", "4.0")
|
|
48
|
+
gi.require_version("Gtk4LayerShell", "1.0")
|
|
49
|
+
from gi.repository import Gtk, Gdk, GLib, Gtk4LayerShell as LayerShell
|
|
50
|
+
|
|
51
|
+
# ---- tuning (copy your working values from wiimote-pointer.py) ----------
|
|
52
|
+
SENS = args.sensitivity # spotlight pixels per raw gyro unit per report
|
|
53
|
+
DEADZONE = 60 # raw units ignored around zero
|
|
54
|
+
CALIB_SAMPLES = 300 # ~3 s
|
|
55
|
+
YAW, YAW_SIGN = e.ABS_RX, 1
|
|
56
|
+
PITCH, PITCH_SIGN = e.ABS_RZ, -1
|
|
57
|
+
|
|
58
|
+
# ---- look ---------------------------------------------------------------
|
|
59
|
+
RADIUS = args.radius # spotlight radius in logical pixels
|
|
60
|
+
SHADE = 0.35 # darkness outside the spotlight (0..1)
|
|
61
|
+
RING = (27 / 255, 131 / 255, 35 / 255, 1.0) # border colour RGBA; set RING_WIDTH = 0 for none
|
|
62
|
+
RING_WIDTH = 3
|
|
63
|
+
|
|
64
|
+
# ---- buttons -> keys (sent via uinput) ----------------------------------
|
|
65
|
+
KEYMAP = {
|
|
66
|
+
e.BTN_A: [e.KEY_RIGHT],
|
|
67
|
+
e.KEY_NEXT: [e.KEY_RIGHT], # "+"
|
|
68
|
+
e.KEY_PREVIOUS: [e.KEY_LEFT], # "-"
|
|
69
|
+
e.KEY_RIGHT: [e.KEY_RIGHT],
|
|
70
|
+
e.KEY_LEFT: [e.KEY_LEFT],
|
|
71
|
+
e.KEY_UP: [e.KEY_UP],
|
|
72
|
+
e.KEY_DOWN: [e.KEY_DOWN],
|
|
73
|
+
e.BTN_2: [e.KEY_ESC],
|
|
74
|
+
}
|
|
75
|
+
# --------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
Gtk.init()
|
|
79
|
+
monitors = Gdk.Display.get_default().get_monitors()
|
|
80
|
+
monitors = [monitors.get_item(i) for i in range(monitors.get_n_items())]
|
|
81
|
+
if args.list_monitors:
|
|
82
|
+
print(format_monitors([(m.get_connector(), m.get_description())
|
|
83
|
+
for m in monitors]))
|
|
84
|
+
return
|
|
85
|
+
monitor = monitors[resolve_monitor(args.monitor,
|
|
86
|
+
[m.get_connector() for m in monitors])]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def find(name):
|
|
90
|
+
for path in evdev.list_devices():
|
|
91
|
+
dev = evdev.InputDevice(path)
|
|
92
|
+
if dev.name == name:
|
|
93
|
+
return dev
|
|
94
|
+
raise SystemExit(f'"{name}" not found. Is the Wiimote connected, and do '
|
|
95
|
+
"you have access to /dev/input (see udev rule)?")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
btn = find("Nintendo Wii Remote")
|
|
99
|
+
mp = find("Nintendo Wii Remote Motion Plus")
|
|
100
|
+
btn.grab()
|
|
101
|
+
mp.grab()
|
|
102
|
+
ui = UInput({e.EV_KEY: sorted({k for ks in KEYMAP.values() for k in ks})},
|
|
103
|
+
name="wiimote-spotlight-keys")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class State:
|
|
107
|
+
visible = False
|
|
108
|
+
x = y = 0.0
|
|
109
|
+
w = h = 1
|
|
110
|
+
gyro = {}
|
|
111
|
+
bias = None
|
|
112
|
+
n = 0
|
|
113
|
+
sums = {YAW: 0, PITCH: 0}
|
|
114
|
+
|
|
115
|
+
def recalibrate(self):
|
|
116
|
+
self.bias, self.n, self.sums = None, 0, {YAW: 0, PITCH: 0}
|
|
117
|
+
self.visible = False
|
|
118
|
+
print("Calibrating gyro, keep the remote still...")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
s = State()
|
|
122
|
+
area = None # Gtk.DrawingArea, set on activate
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def redraw():
|
|
126
|
+
if area is not None:
|
|
127
|
+
area.queue_draw()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ---- input handling -----------------------------------------------------
|
|
131
|
+
def on_gyro(ev):
|
|
132
|
+
if ev.type == e.EV_ABS:
|
|
133
|
+
s.gyro[ev.code] = ev.value
|
|
134
|
+
return
|
|
135
|
+
if ev.type != e.EV_SYN or YAW not in s.gyro or PITCH not in s.gyro:
|
|
136
|
+
return
|
|
137
|
+
if s.bias is None:
|
|
138
|
+
s.sums[YAW] += s.gyro[YAW]
|
|
139
|
+
s.sums[PITCH] += s.gyro[PITCH]
|
|
140
|
+
s.n += 1
|
|
141
|
+
if s.n >= CALIB_SAMPLES:
|
|
142
|
+
s.bias = {k: v / s.n for k, v in s.sums.items()}
|
|
143
|
+
print("Calibrated. Hold B to show the spotlight.")
|
|
144
|
+
return
|
|
145
|
+
if not s.visible:
|
|
146
|
+
return
|
|
147
|
+
moved = False
|
|
148
|
+
for ax, sign, attr, limit in ((YAW, YAW_SIGN, "x", s.w),
|
|
149
|
+
(PITCH, PITCH_SIGN, "y", s.h)):
|
|
150
|
+
v = s.gyro[ax] - s.bias[ax]
|
|
151
|
+
if abs(v) >= DEADZONE:
|
|
152
|
+
new = min(max(getattr(s, attr) + sign * v * SENS, 0), limit)
|
|
153
|
+
setattr(s, attr, new)
|
|
154
|
+
moved = True
|
|
155
|
+
if moved:
|
|
156
|
+
redraw()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def on_button(ev):
|
|
160
|
+
if ev.type != e.EV_KEY or ev.value == 2:
|
|
161
|
+
return
|
|
162
|
+
if ev.code == e.BTN_B:
|
|
163
|
+
if ev.value and s.bias is not None:
|
|
164
|
+
s.visible, s.x, s.y = True, s.w / 2, s.h / 2
|
|
165
|
+
else:
|
|
166
|
+
s.visible = False
|
|
167
|
+
redraw()
|
|
168
|
+
return
|
|
169
|
+
if ev.code == e.BTN_MODE and ev.value:
|
|
170
|
+
s.recalibrate()
|
|
171
|
+
redraw()
|
|
172
|
+
return
|
|
173
|
+
keys = KEYMAP.get(ev.code)
|
|
174
|
+
if keys:
|
|
175
|
+
for k in (keys if ev.value else reversed(keys)):
|
|
176
|
+
ui.write(e.EV_KEY, k, ev.value)
|
|
177
|
+
ui.syn()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def watch(dev, handler, app):
|
|
181
|
+
def cb(_source, _cond):
|
|
182
|
+
try:
|
|
183
|
+
for ev in dev.read():
|
|
184
|
+
handler(ev)
|
|
185
|
+
except BlockingIOError:
|
|
186
|
+
pass
|
|
187
|
+
except OSError:
|
|
188
|
+
print("Wiimote disconnected.")
|
|
189
|
+
app.quit()
|
|
190
|
+
return False
|
|
191
|
+
return True
|
|
192
|
+
GLib.io_add_watch(dev.fd, GLib.PRIORITY_DEFAULT, GLib.IO_IN, cb)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ---- overlay ------------------------------------------------------------
|
|
196
|
+
def draw(_area, cr, w, h, _data=None):
|
|
197
|
+
s.w, s.h = w, h
|
|
198
|
+
cr.set_operator(cairo.OPERATOR_SOURCE)
|
|
199
|
+
cr.set_source_rgba(0, 0, 0, 0)
|
|
200
|
+
cr.paint()
|
|
201
|
+
if not s.visible:
|
|
202
|
+
return
|
|
203
|
+
cr.set_operator(cairo.OPERATOR_OVER)
|
|
204
|
+
cr.set_fill_rule(cairo.FILL_RULE_EVEN_ODD)
|
|
205
|
+
cr.rectangle(0, 0, w, h)
|
|
206
|
+
cr.new_sub_path()
|
|
207
|
+
cr.arc(s.x, s.y, RADIUS, 0, 2 * math.pi)
|
|
208
|
+
cr.set_source_rgba(0, 0, 0, SHADE)
|
|
209
|
+
cr.fill()
|
|
210
|
+
if RING_WIDTH:
|
|
211
|
+
cr.new_sub_path()
|
|
212
|
+
cr.arc(s.x, s.y, RADIUS, 0, 2 * math.pi)
|
|
213
|
+
cr.set_line_width(RING_WIDTH)
|
|
214
|
+
cr.set_source_rgba(*RING)
|
|
215
|
+
cr.stroke()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def on_activate(app):
|
|
219
|
+
nonlocal area
|
|
220
|
+
win = Gtk.ApplicationWindow(application=app)
|
|
221
|
+
LayerShell.init_for_window(win)
|
|
222
|
+
LayerShell.set_namespace(win, "wiimote-spotlight")
|
|
223
|
+
LayerShell.set_layer(win, LayerShell.Layer.OVERLAY)
|
|
224
|
+
for edge in (LayerShell.Edge.LEFT, LayerShell.Edge.RIGHT,
|
|
225
|
+
LayerShell.Edge.TOP, LayerShell.Edge.BOTTOM):
|
|
226
|
+
LayerShell.set_anchor(win, edge, True)
|
|
227
|
+
LayerShell.set_exclusive_zone(win, -1)
|
|
228
|
+
LayerShell.set_keyboard_mode(win, LayerShell.KeyboardMode.NONE)
|
|
229
|
+
LayerShell.set_monitor(win, monitor)
|
|
230
|
+
|
|
231
|
+
css = Gtk.CssProvider()
|
|
232
|
+
css.load_from_data(b"window { background: transparent; }")
|
|
233
|
+
Gtk.StyleContext.add_provider_for_display(
|
|
234
|
+
Gdk.Display.get_default(), css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
|
|
235
|
+
|
|
236
|
+
area = Gtk.DrawingArea()
|
|
237
|
+
area.set_draw_func(draw)
|
|
238
|
+
win.set_child(area)
|
|
239
|
+
# Empty input region = every click and scroll passes straight through.
|
|
240
|
+
win.connect("realize",
|
|
241
|
+
lambda w: w.get_surface().set_input_region(cairo.Region()))
|
|
242
|
+
win.present()
|
|
243
|
+
|
|
244
|
+
watch(mp, on_gyro, app)
|
|
245
|
+
watch(btn, on_button, app)
|
|
246
|
+
s.recalibrate()
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
app = Gtk.Application(application_id="local.wiimote.spotlight")
|
|
250
|
+
app.connect("activate", on_activate)
|
|
251
|
+
try:
|
|
252
|
+
app.run(None)
|
|
253
|
+
finally:
|
|
254
|
+
ui.close()
|
|
255
|
+
|
|
256
|
+
if __name__ == "__main__":
|
|
257
|
+
main()
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
LAPTOP_SCREEN = "eDP-1"
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def default_monitor(connectors):
|
|
5
|
+
if LAPTOP_SCREEN not in connectors:
|
|
6
|
+
return 0
|
|
7
|
+
if len(connectors) == 2:
|
|
8
|
+
external = [name for name in connectors if name != LAPTOP_SCREEN][0]
|
|
9
|
+
return connectors.index(external)
|
|
10
|
+
return connectors.index(LAPTOP_SCREEN)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def resolve_monitor(spec, connectors):
|
|
14
|
+
if spec is None:
|
|
15
|
+
return default_monitor(connectors)
|
|
16
|
+
if spec.isdigit() and int(spec) < len(connectors):
|
|
17
|
+
return int(spec)
|
|
18
|
+
if spec in connectors:
|
|
19
|
+
return connectors.index(spec)
|
|
20
|
+
available = ", ".join(f"{i}: {name}" for i, name in enumerate(connectors))
|
|
21
|
+
raise SystemExit(f'Monitor "{spec}" not found. Available: {available}')
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def format_monitors(monitors):
|
|
25
|
+
lines = []
|
|
26
|
+
for i, (connector, description) in enumerate(monitors):
|
|
27
|
+
line = f"{i}: {connector}"
|
|
28
|
+
if description:
|
|
29
|
+
line += f" ({description})"
|
|
30
|
+
lines.append(line)
|
|
31
|
+
return "\n".join(lines)
|