snapshot of https://github.com/ActivityWatch/aw-watcher-afk.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build / Test on macos-latest (py-3.9) (push) Has been cancelled
Build / Test on ubuntu-24.04 (py-3.9) (push) Has been cancelled
Build / Test on windows-latest (py-3.9) (push) Has been cancelled
CodeQL / Analyze (python) (push) Failing after 1m36s

This commit is contained in:
upstream-mirror
2026-09-16 00:21:02 +05:00
commit 87aaa2add8
22 changed files with 2929 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
from .__main__ import main
__all__ = ["main"]

View File

@@ -0,0 +1,25 @@
from aw_core.log import setup_logging
from aw_watcher_afk.afk import AFKWatcher
from aw_watcher_afk.config import parse_args
def main() -> None:
args = parse_args()
# Set up logging
setup_logging(
"aw-watcher-afk",
testing=args.testing,
verbose=args.verbose,
log_stderr=True,
log_file=True,
)
# Start watcher
watcher = AFKWatcher(args, testing=args.testing)
watcher.run()
if __name__ == "__main__":
main()

136
aw_watcher_afk/afk.py Executable file
View File

@@ -0,0 +1,136 @@
import logging
import os
import platform
from datetime import datetime, timedelta, timezone
from time import sleep
from aw_client import ActivityWatchClient
from aw_core.models import Event
from .config import load_config
system = platform.system()
if system == "Windows":
# noreorder
from .windows import seconds_since_last_input # fmt: skip
elif system == "Darwin":
# noreorder
from .macos import seconds_since_last_input # fmt: skip
elif system == "Linux":
# noreorder
from .unix import seconds_since_last_input # fmt: skip
else:
raise Exception(f"Unsupported platform: {system}")
logger = logging.getLogger(__name__)
td1ms = timedelta(milliseconds=1)
class Settings:
def __init__(self, config_section, timeout=None, poll_time=None):
# Time without input before we're considering the user as AFK
self.timeout = timeout or config_section["timeout"]
# How often we should poll for input activity
self.poll_time = poll_time or config_section["poll_time"]
assert self.timeout >= self.poll_time
class AFKWatcher:
def __init__(self, args, testing=False):
# Read settings from config
self.settings = Settings(
load_config(testing), timeout=args.timeout, poll_time=args.poll_time
)
self.client = ActivityWatchClient(
"aw-watcher-afk", host=args.host, port=args.port, testing=testing
)
self.bucketname = "{}_{}".format(
self.client.client_name, self.client.client_hostname
)
# Store initial parent PID for orphan detection
self._initial_ppid = os.getppid()
def ping(self, afk: bool, timestamp: datetime, duration: float = 0):
data = {"status": "afk" if afk else "not-afk"}
e = Event(timestamp=timestamp, duration=duration, data=data)
pulsetime = self.settings.timeout + self.settings.poll_time
self.client.heartbeat(self.bucketname, e, pulsetime=pulsetime, queued=True)
def run(self):
logger.info("aw-watcher-afk started")
# Initialization
self.client.wait_for_start()
eventtype = "afkstatus"
self.client.create_bucket(self.bucketname, eventtype, queued=True)
# Start afk checking loop
with self.client:
self.heartbeat_loop()
def heartbeat_loop(self):
afk = False
while True:
try:
if system in ["Darwin", "Linux"] and os.getppid() != self._initial_ppid:
logger.info(
"afkwatcher stopped because parent process died "
f"(ppid changed from {self._initial_ppid} to {os.getppid()})"
)
break
now = datetime.now(timezone.utc)
try:
seconds_since_input = seconds_since_last_input()
except OSError:
if system != "Windows":
raise
logger.exception(
"seconds_since_last_input() failed; "
"retrying without changing AFK state"
)
sleep(self.settings.poll_time)
continue
last_input = now - timedelta(seconds=seconds_since_input)
logger.debug(f"Seconds since last input: {seconds_since_input}")
# If no longer AFK
if afk and seconds_since_input < self.settings.timeout:
logger.info("No longer AFK")
self.ping(afk, timestamp=last_input)
afk = False
# ping with timestamp+1ms with the next event (to ensure the latest event gets retrieved by get_event)
self.ping(afk, timestamp=last_input + td1ms)
# If becomes AFK
elif not afk and seconds_since_input >= self.settings.timeout:
logger.info("Became AFK")
self.ping(afk, timestamp=last_input)
afk = True
# ping with timestamp+1ms with the next event (to ensure the latest event gets retrieved by get_event)
self.ping(
afk, timestamp=last_input + td1ms, duration=seconds_since_input
)
# Send a heartbeat if no state change was made
else:
if afk:
# we need the +1ms here too, to make sure we don't "miss" the last heartbeat
# (if last_input hasn't changed)
self.ping(
afk,
timestamp=last_input + td1ms,
duration=seconds_since_input,
)
else:
self.ping(afk, timestamp=last_input)
sleep(self.settings.poll_time)
except KeyboardInterrupt:
logger.info("aw-watcher-afk stopped by keyboard interrupt")
break

51
aw_watcher_afk/config.py Normal file
View File

@@ -0,0 +1,51 @@
import argparse
import sys
from aw_core.config import load_config_toml
default_config = """
[aw-watcher-afk]
timeout = 180
poll_time = 5
[aw-watcher-afk-testing]
timeout = 20
poll_time = 1
""".strip()
def load_config(testing: bool):
section = "aw-watcher-afk" + ("-testing" if testing else "")
return load_config_toml("aw-watcher-afk", default_config)[section]
def parse_args():
# get testing in a dirty way, because we need it for the config lookup
testing = "--testing" in sys.argv
config = load_config(testing)
default_poll_time = config["poll_time"]
default_timeout = config["timeout"]
parser = argparse.ArgumentParser(
description="A watcher for keyboard and mouse input to detect AFK state."
)
parser.add_argument("--host", dest="host")
parser.add_argument("--port", dest="port")
parser.add_argument(
"--testing", dest="testing", action="store_true", help="run in testing mode"
)
parser.add_argument(
"--verbose",
dest="verbose",
action="store_true",
help="run with verbose logging",
)
parser.add_argument(
"--timeout", dest="timeout", type=float, default=default_timeout
)
parser.add_argument(
"--poll-time", dest="poll_time", type=float, default=default_poll_time
)
parsed_args = parser.parse_args()
return parsed_args

295
aw_watcher_afk/listeners.py Executable file
View File

@@ -0,0 +1,295 @@
"""
Listeners for aggregated keyboard and mouse events.
This is used for AFK detection on Linux, as well as used in aw-watcher-input to track input activity in general.
NOTE: Logging usage should be commented out before committed, for performance reasons.
"""
import logging
import threading
from abc import ABCMeta, abstractmethod
from collections import defaultdict
from typing import Dict, Any, List
logger = logging.getLogger(__name__)
# logger.setLevel(logging.DEBUG)
class EventFactory(metaclass=ABCMeta):
def __init__(self) -> None:
self.new_event = threading.Event()
self._reset_data()
@abstractmethod
def _reset_data(self) -> None:
self.event_data: Dict[str, Any] = {}
def next_event(self) -> dict:
"""Returns an event and prepares the internal state so that it can start to build a new event"""
self.new_event.clear()
data = self.event_data
# self.logger.debug(f"Event: {data}")
self._reset_data()
return data
def has_new_event(self) -> bool:
return self.new_event.is_set()
class KeyboardListener(EventFactory):
def __init__(self):
EventFactory.__init__(self)
self.logger = logger.getChild("keyboard")
self._listener = None
def start(self):
from pynput import keyboard
self._listener = keyboard.Listener(
on_press=self.on_press, on_release=self.on_release
)
self._listener.start()
def stop(self):
if self._listener is not None:
self._listener.stop()
def is_alive(self) -> bool:
return self._listener is not None and self._listener.is_alive()
def _reset_data(self):
self.event_data = {"presses": 0}
def on_press(self, key):
# self.logger.debug(f"Press: {key}")
self.event_data["presses"] += 1
self.new_event.set()
def on_release(self, key):
# Don't count releases, only clicks
# self.logger.debug(f"Release: {key}")
pass
class MouseListener(EventFactory):
def __init__(self):
EventFactory.__init__(self)
self.logger = logger.getChild("mouse")
self.pos = None
self._listener = None
def _reset_data(self):
self.event_data = defaultdict(int)
self.event_data.update(
{"clicks": 0, "deltaX": 0, "deltaY": 0, "scrollX": 0, "scrollY": 0}
)
def start(self):
from pynput import mouse
self._listener = mouse.Listener(
on_move=self.on_move, on_click=self.on_click, on_scroll=self.on_scroll
)
self._listener.start()
def stop(self):
if self._listener is not None:
self._listener.stop()
def is_alive(self) -> bool:
return self._listener is not None and self._listener.is_alive()
def on_move(self, x, y):
newpos = (x, y)
# self.logger.debug("Moved mouse to: {},{}".format(x, y))
if not self.pos:
self.pos = newpos
delta = tuple(self.pos[i] - newpos[i] for i in range(2))
self.event_data["deltaX"] += abs(delta[0])
self.event_data["deltaY"] += abs(delta[1])
self.pos = newpos
self.new_event.set()
def on_click(self, x, y, button, down):
# self.logger.debug(f"Click: {button} at {(x, y)}")
# Only count presses, not releases
if down:
self.event_data["clicks"] += 1
self.new_event.set()
def on_scroll(self, x, y, scrollx, scrolly):
# self.logger.debug(f"Scroll: {scrollx}, {scrolly} at {(x, y)}")
self.event_data["scrollX"] += abs(scrollx)
self.event_data["scrollY"] += abs(scrolly)
self.new_event.set()
class GamepadListener(EventFactory):
"""Listens for gamepad/joystick button events via evdev (Linux only, optional).
Requires the ``evdev`` package and read access to ``/dev/input/`` device files.
On most distros users in the ``input`` group have the required access.
Only button press events are counted (not releases), so a held button does
not continuously trigger "not AFK". Analog axis events are intentionally
ignored to avoid false positives from stick drift.
"""
def __init__(self):
EventFactory.__init__(self)
self.logger = logger.getChild("gamepad")
self._threads: List[threading.Thread] = []
self._devices = [] # track open devices for cleanup in stop()
self._stop_event = threading.Event()
# True once start() has actually spawned at least one reader thread.
# Used by callers to distinguish "no gamepad ever attached" (don't
# restart) from "gamepad threads died after running" (do restart).
self._was_started = False
def _reset_data(self):
self.event_data = {"buttons": 0}
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def start(self):
try:
import evdev # noqa: F401
except ImportError:
self.logger.debug(
"evdev not installed; gamepad detection unavailable. "
"Install it with: pip install evdev"
)
return
devices = self._find_gamepads()
if not devices:
self.logger.debug("No gamepads/joysticks found in /dev/input/")
return
self.logger.info(
"Gamepad listener started for %d device(s): %s",
len(devices),
[d.name for d in devices],
)
self._stop_event.clear()
self._devices = list(devices) # keep references for cleanup
for device in devices:
t = threading.Thread(
target=self._read_events,
args=(device,),
name=f"gamepad-{device.path}",
daemon=True,
)
t.start()
self._threads.append(t)
self._was_started = True
def stop(self):
self._stop_event.set()
# Close devices to unblock threads stuck in read_loop()
# (the blocking select() call is released when the FD is closed)
for device in self._devices:
try:
device.close()
except (OSError, IOError):
pass
self._devices.clear()
# Wait briefly for threads to finish
for t in self._threads:
t.join(timeout=2.0)
self._threads.clear()
self._was_started = False
def is_alive(self) -> bool:
return any(t.is_alive() for t in self._threads)
def needs_restart(self) -> bool:
"""Return True if start() previously launched threads but any have died.
Used to distinguish a system that simply has no gamepad attached (in
which case start() returned early and no restart is needed) from one
where one or more reader threads have exited unexpectedly (e.g. device
removed or USB error). In the latter case the caller should reinitialize
the listener so all gamepads start being tracked again.
Detects partial failure (one of N gamepads removed) in addition to
total failure (all gamepads removed), so multi-gamepad setups don't
silently lose a device until the last one dies.
"""
if not self._was_started:
return False
# Restart when any reader thread has died, not just when all have.
return any(not t.is_alive() for t in self._threads)
# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
def _find_gamepads(self):
"""Return all readable /dev/input/ devices that look like gamepads."""
import evdev
gamepads = []
for path in evdev.list_devices():
try:
device = evdev.InputDevice(path)
except (OSError, PermissionError):
continue
if self._is_gamepad(device):
gamepads.append(device)
else:
# Close non-gamepad devices to avoid FD leaks
device.close()
return gamepads
@staticmethod
def _is_gamepad(device) -> bool:
"""Return True if *device* appears to be a gamepad or joystick."""
import evdev
caps = device.capabilities()
if evdev.ecodes.EV_KEY not in caps:
return False
# A subset of button codes that only appear on gamepads/joysticks
# Note: BTN_GAMEPAD == BTN_SOUTH (both 0x130) in the Linux input subsystem;
# BTN_SOUTH is kept as the more descriptive name and BTN_GAMEPAD omitted.
gamepad_btns = {
evdev.ecodes.BTN_SOUTH, # Xbox A / PS Cross (also == BTN_GAMEPAD)
evdev.ecodes.BTN_EAST, # Xbox B / PS Circle
evdev.ecodes.BTN_NORTH, # Xbox Y / PS Triangle
evdev.ecodes.BTN_WEST, # Xbox X / PS Square
evdev.ecodes.BTN_JOYSTICK, # generic joystick button
evdev.ecodes.BTN_TRIGGER, # joystick trigger
evdev.ecodes.BTN_THUMB, # joystick thumb
evdev.ecodes.BTN_TOP, # joystick top
}
device_btns = set(caps[evdev.ecodes.EV_KEY])
return bool(device_btns & gamepad_btns)
def _read_events(self, device) -> None:
"""Read button events from *device* until stop() is called."""
import evdev
try:
for event in device.read_loop():
if self._stop_event.is_set():
break
# Count button *press* events only (value == 1)
if event.type == evdev.ecodes.EV_KEY and event.value == 1:
# self.logger.debug(f"Gamepad button press: {event.code}")
self.event_data["buttons"] += 1
self.new_event.set()
except (OSError, IOError):
# Device disconnected or permission lost — stop quietly
self.logger.debug("Gamepad device %s disconnected", device.path)
finally:
# Always close the device FD on thread exit
try:
device.close()
except (OSError, IOError):
pass

14
aw_watcher_afk/macos.py Executable file
View File

@@ -0,0 +1,14 @@
from Quartz.CoreGraphics import (CGEventSourceSecondsSinceLastEventType,
kCGEventSourceStateHIDSystemState,
kCGAnyInputEventType)
def seconds_since_last_input() -> float:
return CGEventSourceSecondsSinceLastEventType(kCGEventSourceStateHIDSystemState, kCGAnyInputEventType)
if __name__ == "__main__":
from time import sleep
while True:
sleep(1)
print(seconds_since_last_input())

114
aw_watcher_afk/unix.py Executable file
View File

@@ -0,0 +1,114 @@
import logging
from datetime import datetime
from time import sleep
from .listeners import GamepadListener, KeyboardListener, MouseListener
class LastInputUnix:
def __init__(self):
self.logger = logging.getLogger(__name__)
# self.logger.setLevel(logging.DEBUG)
self._start_listeners()
self.last_activity = datetime.now()
def _start_listeners(self):
self.mouseListener = MouseListener()
self.mouseListener.start()
self.keyboardListener = KeyboardListener()
self.keyboardListener.start()
# Optional: gamepad support via evdev (Linux only).
# Starts silently if evdev is not installed or no gamepads are found.
self.gamepadListener = GamepadListener()
self.gamepadListener.start()
def _stop_listeners(self):
"""Stop existing listeners to avoid duplicate instances."""
if self.mouseListener.is_alive():
self.mouseListener.stop()
if self.keyboardListener.is_alive():
self.keyboardListener.stop()
if self.gamepadListener.is_alive():
self.gamepadListener.stop()
def _check_listeners(self):
"""Check if input listeners are still alive, restart if dead.
Pynput listeners can silently die when the X server restarts
(e.g. after suspend/resume, display server crash, or session switch).
Without this check, the watcher would permanently report AFK.
See: https://github.com/ActivityWatch/aw-watcher-afk/issues/27
"""
# Only restart the gamepad listener if it was actually running before
# and all its threads died (e.g. device unplugged). Without this guard,
# systems without a gamepad would trigger a restart on every poll,
# since is_alive() is always False when start() returned early.
gamepad_died = self.gamepadListener.needs_restart()
if (
not self.mouseListener.is_alive()
or not self.keyboardListener.is_alive()
or gamepad_died
):
mouse_kb_died = (
not self.mouseListener.is_alive()
or not self.keyboardListener.is_alive()
)
if gamepad_died and mouse_kb_died:
self.logger.warning(
"All input listeners died (X server restart?), reinitializing..."
)
elif gamepad_died:
self.logger.warning(
"Gamepad listener died (device removed?), reinitializing..."
)
else:
self.logger.warning(
"Input listeners died (X server restart?), reinitializing..."
)
# Stop any still-running listeners before creating new ones
# to avoid duplicate listener instances (e.g. if only one died)
self._stop_listeners()
self._start_listeners()
# Reset last_activity so we don't report a huge AFK gap
self.last_activity = datetime.now()
def seconds_since_last_input(self) -> float:
# TODO: This has a delay of however often it is called.
# Could be solved by creating a custom listener.
self._check_listeners()
now = datetime.now()
has_event = (
self.mouseListener.has_new_event()
or self.keyboardListener.has_new_event()
or self.gamepadListener.has_new_event()
)
if has_event:
self.logger.debug("New event")
self.last_activity = now
# Get/clear events
self.mouseListener.next_event()
self.keyboardListener.next_event()
self.gamepadListener.next_event()
return (now - self.last_activity).total_seconds()
_last_input_unix = None
def seconds_since_last_input():
global _last_input_unix
if _last_input_unix is None:
_last_input_unix = LastInputUnix()
return _last_input_unix.seconds_since_last_input()
if __name__ == "__main__":
while True:
sleep(1)
print(seconds_since_last_input())

54
aw_watcher_afk/windows.py Normal file
View File

@@ -0,0 +1,54 @@
import ctypes
import time
from ctypes import POINTER, Structure, c_ulonglong
from ctypes.wintypes import BOOL, DWORD, UINT
ULONGLONG = c_ulonglong
class LastInputInfo(Structure):
_fields_ = [("cbSize", UINT), ("dwTime", DWORD)]
def _getLastInputTick() -> int:
prototype = ctypes.WINFUNCTYPE(BOOL, POINTER(LastInputInfo)) # type: ignore[attr-defined]
paramflags = ((1, "lastinputinfo"),)
c_GetLastInputInfo = prototype(("GetLastInputInfo", ctypes.windll.user32), paramflags) # type: ignore
lastinput = LastInputInfo()
lastinput.cbSize = ctypes.sizeof(LastInputInfo)
if 0 == c_GetLastInputInfo(lastinput):
raise OSError("GetLastInputInfo failed")
return lastinput.dwTime
def _getTickCount64() -> int:
"""Use GetTickCount64 to avoid 32-bit overflow after ~49.7 days of uptime."""
prototype = ctypes.WINFUNCTYPE(ULONGLONG) # type: ignore[attr-defined]
paramflags = ()
c_GetTickCount64 = prototype(("GetTickCount64", ctypes.windll.kernel32), paramflags) # type: ignore
return c_GetTickCount64()
def seconds_since_last_input() -> float:
tick_count = _getTickCount64()
last_input_tick = _getLastInputTick() # 32-bit DWORD from GetLastInputInfo
# GetLastInputInfo returns a 32-bit DWORD tick count that wraps at 2^32 ms.
# GetTickCount64 returns 64-bit. To compute the difference correctly, we
# compare only the lower 32 bits when the values are close (normal case),
# and handle the wraparound when dwTime > lower 32 bits of tick_count.
tick_lower32 = tick_count & 0xFFFFFFFF
if tick_lower32 >= last_input_tick:
diff_ms = tick_lower32 - last_input_tick
else:
# 32-bit wraparound: tick_count's lower bits wrapped past dwTime
diff_ms = (0x100000000 - last_input_tick) + tick_lower32
return diff_ms / 1000
if __name__ == "__main__":
while True:
time.sleep(1)
print(seconds_since_last_input())