Skip to content

Commit 054e264

Browse files
committed
feat: add evdev-holder daemon to prevent LED pulsing on affected hardware
Keep evdev device fds permanently open so the kernel never re-triggers driver open callbacks (input_open_device) on subsequent opens. This eliminates LED pulsing caused by repeated open/close cycles on hardware like Tuxedo Stellaris 15 Gen3. See: https://www.reddit.com/r/tuxedocomputers/comments/1rtj3c9/ See: gvalkov/python-evdev#251
1 parent c0660d2 commit 054e264

4 files changed

Lines changed: 247 additions & 3 deletions

File tree

src/numlockw/__main__.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -234,9 +234,9 @@ def off():
234234

235235

236236
def status():
237-
# Use readonly=True to avoid O_RDWR open which can trigger LED re-assertion
238-
# on certain hardware (e.g. Tuxedo Stellaris touchpad LED pulsing).
239-
# See: https://www.reddit.com/r/tuxedocomputers/comments/1rtj3c9/numlockw_status_causes_touchpad_led_to_pulse/
237+
# Use readonly=True to skip the unnecessary O_RDWR attempt when only
238+
# reading LED state. For the LED pulsing fix on affected hardware
239+
# (e.g. Tuxedo Stellaris), see workarounds/evdev-holder/.
240240
_debug("status() called - checking NumLock status")
241241
filter_name = "*" if device_name is None else device_name
242242
_debug(f"Using device filter: {filter_name!r}")

workarounds/evdev-holder/README.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# evdev-holder
2+
3+
A lightweight daemon that prevents LED pulsing on certain hardware (e.g. Tuxedo Stellaris 15 Gen3) by keeping evdev input device file descriptors permanently open.
4+
5+
Pure Python. No external dependencies.
6+
7+
## Problem
8+
9+
On affected hardware, every time a program opens and closes an `/dev/input/event*` device, the kernel re-asserts LED states to the embedded controller. The firmware interprets this as a state transition, causing a visible LED pulse. Tools like `numlockw status` that poll at 1-2 Hz make the touchpad LED flash constantly.
10+
11+
Kernel call chain on every `open()` when no other fd is held:
12+
13+
```
14+
open("/dev/input/eventX")
15+
→ evdev_open_device() # evdev->open goes 0 → 1
16+
→ input_open_device() # dev->users goes 0 → 1
17+
→ dev->open() # hardware driver callback
18+
→ e.g. hidinput_open() → hid_hw_open() → transport layer reinit
19+
→ EC firmware pulses LED (hardware/firmware-specific side effect)
20+
```
21+
22+
## Solution
23+
24+
This daemon holds at least one fd open per device. With `evdev->open` permanently >= 1, subsequent opens skip `input_open_device()` entirely, and LEDs are never re-asserted.
25+
26+
## Usage
27+
28+
### Run directly
29+
30+
```bash
31+
sudo python3 evdev_holder.py
32+
```
33+
34+
Options:
35+
36+
| Flag | Description |
37+
|------|-------------|
38+
| `--quiet`, `-q` | Only show warnings and errors |
39+
| `--interval N` | Device rescan interval in seconds (default: 5) |
40+
41+
### Install as systemd service
42+
43+
```bash
44+
# Copy the script
45+
sudo mkdir -p /opt/evdev-holder
46+
sudo cp evdev_holder.py /opt/evdev-holder/
47+
48+
# Install and enable the service
49+
sudo cp evdev-holder.service /etc/systemd/system/
50+
sudo systemctl daemon-reload
51+
sudo systemctl enable --now evdev-holder
52+
```
53+
54+
Check status:
55+
56+
```bash
57+
sudo systemctl status evdev-holder
58+
```
59+
60+
### Uninstall
61+
62+
```bash
63+
sudo systemctl disable --now evdev-holder
64+
sudo rm /etc/systemd/system/evdev-holder.service
65+
sudo rm -rf /opt/evdev-holder
66+
sudo systemctl daemon-reload
67+
```
68+
69+
## See also
70+
71+
- [Reddit report](https://www.reddit.com/r/tuxedocomputers/comments/1rtj3c9/numlockw_status_causes_touchpad_led_to_pulse/)
72+
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[Unit]
2+
Description=Hold evdev input devices open to prevent LED pulsing
3+
Documentation=https://github.com/xz-dev/numlockw
4+
After=systemd-udevd.service
5+
6+
[Service]
7+
Type=simple
8+
ExecStart=/usr/bin/python3 /opt/evdev-holder/evdev_holder.py --quiet
9+
Restart=always
10+
RestartSec=3
11+
12+
[Install]
13+
WantedBy=multi-user.target
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
#!/usr/bin/env python3
2+
"""
3+
evdev-holder: Keep evdev input device file descriptors permanently open.
4+
5+
Workaround for LED pulsing on certain hardware (e.g. Tuxedo Stellaris 15 Gen3).
6+
7+
Root cause:
8+
When all fds to /dev/input/eventX are closed, the kernel's evdev->open count
9+
drops to 0. The next open() triggers:
10+
evdev_open_device() -> input_open_device() -> dev->open()
11+
which calls the hardware driver callback (e.g. hidinput_open -> hid_hw_open).
12+
On affected firmware (e.g. Tuxedo Stellaris EC), this driver reinitialization
13+
causes a visible LED pulse as a side effect.
14+
15+
Fix:
16+
By keeping at least one fd open per device, evdev->open stays >= 1. Subsequent
17+
opens see a truthy evdev->open++ and skip input_open_device() entirely, so the
18+
LED re-assertion path is never reached.
19+
20+
See:
21+
https://www.reddit.com/r/tuxedocomputers/comments/1rtj3c9/
22+
https://github.com/gvalkov/python-evdev/pull/251
23+
24+
Usage:
25+
sudo python3 evdev_holder.py # foreground
26+
sudo python3 evdev_holder.py --quiet # suppress info messages
27+
28+
Pure stdlib Python. No external dependencies.
29+
"""
30+
31+
import argparse
32+
import glob
33+
import logging
34+
import os
35+
import signal
36+
import sys
37+
import time
38+
39+
DEVPATH = "/dev/input/event*"
40+
DEFAULT_INTERVAL = 5 # seconds
41+
42+
log = logging.getLogger("evdev-holder")
43+
44+
45+
def discover_devices():
46+
"""Return sorted list of all /dev/input/event* paths."""
47+
return sorted(glob.glob(DEVPATH))
48+
49+
50+
def open_device(path):
51+
"""Open a device read-only + nonblock. Returns fd or None on failure."""
52+
try:
53+
fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
54+
return fd
55+
except OSError as e:
56+
log.warning("cannot open %s: %s", path, e)
57+
return None
58+
59+
60+
def fd_is_alive(fd):
61+
"""Check if a held fd is still valid (device not unplugged)."""
62+
try:
63+
os.fstat(fd)
64+
return True
65+
except OSError:
66+
return False
67+
68+
69+
def close_fd(fd):
70+
"""Close a file descriptor, ignoring errors."""
71+
try:
72+
os.close(fd)
73+
except OSError:
74+
pass
75+
76+
77+
class EvdevHolder:
78+
def __init__(self, interval=DEFAULT_INTERVAL):
79+
# path -> fd
80+
self.held = {}
81+
self.interval = interval
82+
83+
def scan(self):
84+
"""Open any new devices not already held. Drop dead ones."""
85+
# prune dead fds
86+
dead = [p for p, fd in self.held.items() if not fd_is_alive(fd)]
87+
for path in dead:
88+
log.info("device removed: %s", path)
89+
close_fd(self.held.pop(path))
90+
91+
# open new devices
92+
for path in discover_devices():
93+
if path not in self.held:
94+
fd = open_device(path)
95+
if fd is not None:
96+
self.held[path] = fd
97+
log.info("holding: %s (fd=%d)", path, fd)
98+
99+
def close_all(self):
100+
"""Close all held file descriptors."""
101+
for path, fd in self.held.items():
102+
log.debug("releasing: %s (fd=%d)", path, fd)
103+
close_fd(fd)
104+
count = len(self.held)
105+
self.held.clear()
106+
return count
107+
108+
def run(self):
109+
"""Main loop: scan, sleep, repeat."""
110+
self.scan()
111+
log.info("initial scan: holding %d device(s)", len(self.held))
112+
113+
while True:
114+
time.sleep(self.interval)
115+
self.scan()
116+
117+
118+
def main():
119+
parser = argparse.ArgumentParser(
120+
description="Hold evdev input devices open to prevent LED pulsing on affected hardware."
121+
)
122+
parser.add_argument(
123+
"--quiet", "-q",
124+
action="store_true",
125+
help="Only show warnings and errors.",
126+
)
127+
parser.add_argument(
128+
"--interval",
129+
type=float,
130+
default=DEFAULT_INTERVAL,
131+
help=f"Device rescan interval in seconds (default: {DEFAULT_INTERVAL}).",
132+
)
133+
args = parser.parse_args()
134+
135+
# logging setup
136+
handler = logging.StreamHandler(sys.stderr)
137+
handler.setFormatter(logging.Formatter("[evdev-holder] %(message)s"))
138+
log.addHandler(handler)
139+
log.setLevel(logging.WARNING if args.quiet else logging.INFO)
140+
141+
holder = EvdevHolder(interval=args.interval)
142+
143+
# graceful shutdown
144+
def shutdown(signum, _frame):
145+
signame = signal.Signals(signum).name
146+
log.info("received %s, releasing all devices...", signame)
147+
count = holder.close_all()
148+
log.info("released %d device(s), exiting", count)
149+
sys.exit(0)
150+
151+
signal.signal(signal.SIGTERM, shutdown)
152+
signal.signal(signal.SIGINT, shutdown)
153+
154+
log.info("starting (rescan interval: %.1fs)", args.interval)
155+
holder.run()
156+
157+
158+
if __name__ == "__main__":
159+
main()

0 commit comments

Comments
 (0)