feat(afk): add gamepad/joystick support via evdev (Linux)#80
Conversation
Fixes #1215 — controller input no longer causes false AFK when playing games on Linux. Closes ActivityWatch#20. Adds a `GamepadListener` class that uses `python-evdev` to monitor button press events from connected gamepads and joysticks. When a button is pressed, the AFK timer is reset exactly like a keyboard or mouse event. Key design decisions: - **Optional dependency**: evdev is not required. If it's absent, or if no gamepad is plugged in, `GamepadListener.start()` is a no-op and the watcher works exactly as before. - **Buttons only, no axes**: Analog stick/trigger axis events are intentionally ignored to prevent stick drift from falsely suppressing AFK detection. Only explicit button presses (EV_KEY, value=1) count. - **Per-device threads**: Each gamepad gets its own daemon thread so hotplug disconnect (OSError from read_loop) stops cleanly without killing the others. - **No permissions change needed**: Steam, most gaming distros, and the `input` udev group already grant access to `/dev/input/event*` for regular users. Changes: - `listeners.py`: new `GamepadListener(EventFactory)` with `_is_gamepad()` detection and `_read_events()` per-device thread loop - `unix.py`: `LastInputUnix` starts/stops/checks the gamepad listener alongside keyboard and mouse; gamepad events reset `last_activity` - `pyproject.toml`: `evdev >=1.4` as optional Linux dependency; new `[gamepad]` extra: `pip install aw-watcher-afk[gamepad]` - `tests/test_listener_health.py`: 6 new tests covering no-evdev fallback, no-devices fallback, button press detection, and AFK timer reset
|
@greptileai review |
Greptile SummaryThis PR adds an optional Key changes:
One minor remaining nit: Confidence Score: 5/5Safe to merge — all previously raised P0/P1 concerns have been resolved; only a cosmetic duplicate-constant nit remains. All prior P1 findings confirmed fixed. The single remaining finding is a P2 style issue (BTN_TRIGGER == BTN_JOYSTICK duplicate) that has no effect on runtime correctness. Minor: aw_watcher_afk/listeners.py line 242 — cosmetic duplicate constant in _is_gamepad(). Important Files Changed
Sequence DiagramsequenceDiagram
participant AFK as aw-watcher-afk
participant Unix as LastInputUnix
participant KL as KeyboardListener
participant ML as MouseListener
participant GL as GamepadListener
participant evdev as evdev / /dev/input
AFK->>Unix: seconds_since_last_input()
Unix->>Unix: _check_listeners()
Unix->>KL: is_alive()?
Unix->>ML: is_alive()?
Note over Unix: on X restart - _stop_listeners() + _start_listeners()
Unix->>KL: has_new_event()?
Unix->>ML: has_new_event()?
Unix->>GL: has_new_event()?
alt any listener has event
Unix->>Unix: last_activity = now
Unix->>KL: next_event()
Unix->>ML: next_event()
Unix->>GL: next_event()
end
Unix-->>AFK: seconds elapsed
Note over GL,evdev: Background daemon threads
GL->>evdev: _find_gamepads()
loop per device thread
evdev-->>GL: EV_KEY event (value==1)
GL->>GL: event_data buttons += 1
GL->>GL: new_event.set()
end
Note over GL,evdev: On stop()
GL->>evdev: device.close()
GL->>GL: thread.join(timeout=2s)
Reviews (4): Last reviewed commit: "fix(afk): address Greptile review findin..." | Re-trigger Greptile |
|
@greptileai review |
Greptile Review ResponseGood catches. Two fixes needed before merge: 1. FD leak in
|
Two resource leak bugs found by Greptile review: 1. FD leak in _find_gamepads(): non-gamepad InputDevice objects were opened but never closed during device scanning. Now calls close() on devices that don't match gamepad criteria. 2. Thread leak in stop(): threads blocked in device.read_loop() (a blocking select() syscall) were never woken by _stop_event.set(). Now stop() closes device FDs to break the select() call, then joins threads with a 2s timeout. Also stores device references in _devices list for cleanup, and _read_events() closes the device FD in a finally block on thread exit.
✅ Fixes pushed for both resource leak bugsTwo commits now address the Greptile findings: 1. FD leak in The approach mirrors how Test coverage for the new code paths should be added — MockGamepad's |
|
@greptileai review |
- Remove dead _GAMEPAD_BTN_CODES class variable (was never read or written, had misleading "populated lazily" comment) - Remove duplicate BTN_GAMEPAD from gamepad detection set (BTN_GAMEPAD == BTN_SOUTH == 0x130; BTN_SOUTH kept as more descriptive name) - Mock evdev import in test_gamepad_listener_starts_with_device so the test passes in environments where evdev is not installed (evdev is optional) - Assert MockGamepad.call_count == 2 in reinitialize test to verify gamepad listener is also restarted on X server death
|
@greptileai review |
Fixes #1215 (controller input causes false AFK). Closes #20.
Problem
When playing games on Linux with a controller, ActivityWatch marks the session as AFK because aw-watcher-afk only monitors keyboard and mouse events. Gamepad button presses are invisible to pynput.
Solution
Adds an optional
GamepadListenerthat usespython-evdevto monitor button press events from connected gamepads/joysticks. A button press resets the AFK timer exactly like a keyboard or mouse event.Design
evdevis not required. If absent, or if no gamepad is connected,GamepadListener.start()silently does nothing — the watcher behaves exactly as before.EV_KEY, value=1) count.OSErrorfromread_loop()and stops its thread cleanly without affecting other devices.inputudev group already grant access to/dev/input/event*for regular users.Install
Changes
listeners.py: newGamepadListener(EventFactory)with_is_gamepad()detection by button codes and_read_events()per-device thread loopunix.py:LastInputUnixstarts/stops/checks the gamepad listener alongside keyboard and mousepyproject.toml:evdev >=1.4as optional Linux dependency; new[gamepad]extratests/test_listener_health.py: 6 new tests — no-evdev fallback, no-devices fallback, mock thread start, button press event, AFK timer reset, existing listener restart unchanged (13 total, all pass)