Skip to content

feat(afk): add gamepad/joystick support via evdev (Linux)#80

Merged
ErikBjare merged 4 commits into
ActivityWatch:masterfrom
TimeToBuildBob:feat/gamepad-support
Mar 31, 2026
Merged

feat(afk): add gamepad/joystick support via evdev (Linux)#80
ErikBjare merged 4 commits into
ActivityWatch:masterfrom
TimeToBuildBob:feat/gamepad-support

Conversation

@TimeToBuildBob

Copy link
Copy Markdown
Contributor

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 GamepadListener that uses python-evdev to monitor button press events from connected gamepads/joysticks. A button press resets the AFK timer exactly like a keyboard or mouse event.

Design

  • Optional: evdev is not required. If absent, or if no gamepad is connected, GamepadListener.start() silently does nothing — the watcher behaves 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 daemon threads: Each gamepad gets its own thread. A disconnected device raises OSError from read_loop() and stops its thread cleanly without affecting other devices.
  • No permission changes needed: Steam, most gaming distros, and the input udev group already grant access to /dev/input/event* for regular users.

Install

# With gamepad support
pip install aw-watcher-afk[gamepad]

# Or just
pip install evdev

Changes

  • listeners.py: new GamepadListener(EventFactory) with _is_gamepad() detection by button codes and _read_events() per-device thread loop
  • unix.py: LastInputUnix starts/stops/checks the gamepad listener alongside keyboard and mouse
  • pyproject.toml: evdev >=1.4 as optional Linux dependency; new [gamepad] extra
  • tests/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)

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
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@greptileai review

@greptile-apps

greptile-apps Bot commented Mar 12, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds an optional GamepadListener backed by python-evdev so that controller button presses reset the AFK timer on Linux, solving the long-standing issue where game sessions were incorrectly marked AFK. The implementation is well-designed: evdev is a soft dependency (import failure and no-device cases both degrade gracefully), only explicit button-press events (EV_KEY, value=1) are counted (axis/drift events are filtered out), each gamepad gets its own daemon thread, and device FDs are closed on stop() to unblock threads rather than leaving them orphaned.

Key changes:

  • listeners.py: new GamepadListener(EventFactory) with clean lifecycle (start / stop / is_alive), per-device threads, FD cleanup on both graceful stop and device disconnect.
  • unix.py: LastInputUnix starts, stops, and polls gamepadListener consistently alongside the existing keyboard and mouse listeners.
  • pyproject.toml / poetry.lock: evdev >=1.4 added as an optional Linux extra ([gamepad]); Poetry 2.1.4 also fixes the sys_platform in \"linux\" marker.
  • tests/test_listener_health.py: 6 new tests covering the no-evdev fallback, no-devices fallback, thread startup, button-press detection, AFK-timer reset, and listener reinitialisation.

One minor remaining nit: BTN_TRIGGER and BTN_JOYSTICK are the same constant (0x120) in the Linux input subsystem, producing a silent duplicate in the _is_gamepad() detection set.

Confidence Score: 5/5

Safe 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

Filename Overview
aw_watcher_afk/listeners.py New GamepadListener class with well-structured lifecycle management (start/stop/is_alive), proper FD cleanup on disconnect and graceful shutdown; minor: BTN_TRIGGER == BTN_JOYSTICK duplicate in _is_gamepad() detection set.
aw_watcher_afk/unix.py Cleanly integrates GamepadListener alongside keyboard/mouse listeners; start/stop/check lifecycle is consistent and handles the optional (no evdev, no devices) case silently.
tests/test_listener_health.py 6 new tests with good coverage of the no-evdev fallback, no-devices fallback, thread start, button press event, AFK timer reset, and reinitialisation.
pyproject.toml Correctly adds evdev >=1.4 as an optional Linux dependency with a [gamepad] extra; no issues.
poetry.lock Auto-generated by Poetry 2.1.4; fixes the sys_platform marker, adds the [extras] gamepad section, and updates the content hash.

Sequence Diagram

sequenceDiagram
    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)
Loading

Reviews (4): Last reviewed commit: "fix(afk): address Greptile review findin..." | Re-trigger Greptile

Comment thread aw_watcher_afk/listeners.py
Comment thread aw_watcher_afk/listeners.py Outdated
Comment thread aw_watcher_afk/listeners.py Outdated
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@greptileai review

Comment thread aw_watcher_afk/listeners.py
Comment thread tests/test_listener_health.py
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Greptile Review Response

Good catches. Two fixes needed before merge:

1. FD leak in _find_gamepads

The InputDevice objects from list_devices() that don't match gamepad criteria need explicit close() calls. Quick fix: wrap the scan in a try/finally or use a context manager pattern. I'll push a fix.

2. Thread unblock on stop()

_stop_event.set() doesn't wake threads blocked in device.read_loop() (which uses select() internally). Fix: after setting the stop event, close the device file descriptors to force select() to return with an error, which the read_loop will interpret as shutdown.

Both are straightforward. Will push fixes shortly.

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

Copy link
Copy Markdown
Contributor Author

✅ Fixes pushed for both resource leak bugs

Two commits now address the Greptile findings:

1. FD leak in _find_gamepads()device.close() now called on non-gamepad devices after capability check
2. Thread leak in stop() → devices are closed (breaking the select() syscall), threads joined with 2s timeout, _devices list tracks open FDs for cleanup, and _read_events() closes FD in finally block on thread exit

The approach mirrors how evdev handles cleanup: closing the device FD is the canonical way to interrupt a blocking read_loop() since the underlying select() returns immediately when the FD is closed.

Test coverage for the new code paths should be added — MockGamepad's call_count == 2 assertion from Greptile's suggestion would verify the restart cycle works correctly.

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@greptileai review

Comment thread tests/test_listener_health.py
- 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
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@greptileai review

@ErikBjare ErikBjare merged commit 4c0375f into ActivityWatch:master Mar 31, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add support for gamepads

2 participants