snapshot of https://github.com/ActivityWatch/aw-watcher-afk.git (master, 2026-09-16; blobs > 131072B omitted)
This commit is contained in:
3
aw_watcher_afk/__init__.py
Normal file
3
aw_watcher_afk/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .__main__ import main
|
||||
|
||||
__all__ = ["main"]
|
||||
25
aw_watcher_afk/__main__.py
Normal file
25
aw_watcher_afk/__main__.py
Normal 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
136
aw_watcher_afk/afk.py
Executable 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
51
aw_watcher_afk/config.py
Normal 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
295
aw_watcher_afk/listeners.py
Executable 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
14
aw_watcher_afk/macos.py
Executable 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
114
aw_watcher_afk/unix.py
Executable 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
54
aw_watcher_afk/windows.py
Normal 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())
|
||||
Reference in New Issue
Block a user