snapshot of https://github.com/ActivityWatch/aw-watcher-afk.git (master, 2026-09-16; blobs > 131072B omitted)
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user