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.
@@ -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)