pax_global_header00006660000000000000000000000064152262323440014515gustar00rootroot0000000000000052 comment=a8f8268cb3a074748e9401b24b05dbafedf2693f joystickwake/000077500000000000000000000000001522623234400135705ustar00rootroot00000000000000joystickwake/.gitattributes000066400000000000000000000000251522623234400164600ustar00rootroot00000000000000debian export-ignore joystickwake/LICENSE000066400000000000000000000020641522623234400145770ustar00rootroot00000000000000Copyright 2014, 2016-2026 Forest Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. joystickwake/MANIFEST.in000066400000000000000000000000431522623234400153230ustar00rootroot00000000000000include LICENSE include README.rst joystickwake/README.rst000066400000000000000000000314141522623234400152620ustar00rootroot00000000000000joystickwake ============ ----------------------------- A joystick-aware screen waker ----------------------------- :Manual section: 1 :Date: 2026-06-07 Synopsis -------- joystickwake -h|--help|--version joystickwake [--command CMD] [--cooldown SECONDS] [--loglevel LEVEL] Description ----------- Linux gamers often find themselves unexpectedly staring at a blank screen, because their display server fails to recognize game controllers as input devices, allowing the screen blanker to activate during gameplay. This program works around the problem by suppressing screen blankers while joystick activity is detected. Overview -------- If installed from a package, joystickwake launches automatically when a user logs in to any modern linux desktop environment. It can also be run manually, without formal installation, since the executable is self-contained. While running, it monitors udev to find devices with the ID_INPUT_JOYSTICK property (including devices that are plugged in later) and reacts to activity from any of them. The screen is kept awake by periodically running a heartbeat-like wake command: an external tool provided by the system. Most desktop environments and screen savers include such a command, and joystickwake knows how to use the common ones, so there is normally no need for a user to install or configure one. This design keeps joystickwake compatible with just about every screensaver and power management system there is. Operation --------- When joystick activity is detected, all known wake commands are executed. Any command reporting an error or missing from the system will be skipped thereafter. This process repeats in a loop, with a cooldown period between wakes. Joystickwake thereby learns which commands are available on the host system, avoids excessive work that might cause game lag, and minimizes log clutter. In practice, it is very lightweight. If the python3 dbus-fast package is installed, joystickwake will also try to inhibit the desktop environment's screen idle state for a short while after detecting activity, using the org.freedesktop.ScreenSaver interface. This helps with certain environments whose heartbeat-style wake interfaces are missing or broken. If all known wakers fail, joystickwake will quit. This could happen if no screen blanker is running, or if one that is running requires a custom wake command (see below). If the python3 Xlib package is installed, joystickwake will quit when the X11 desktop session ends. Otherwise, it will quit when its parent process exits. (This keeps old instances from persisting after the user logs out, on both X and Wayland.) Log messages are written to standard error, which is normally captured by the desktop environment in a file such as ``$HOME/.xsession-errors``. At the default log level, few messages will be produced, if any. Configuration and Options -------------------------- In many desktop environments, no configuration is needed. Joystickwake comes preconfigured with commands that will suppress common screensavers and DPMS power-off. Those commands are:: xset dpms force on xset s reset xscreensaver-command -deactivate gnome-screensaver-command --deactivate mate-screensaver-command --poke xfce4-screensaver-command --poke dbus-send [...] org.freedesktop.ScreenSaver.SimulateUserActivity dbus-send [...] org.freedesktop.ScreenSaver.Inhibit dbus-send [...] org.gnome.ScreenSaver.SetActive false If needed, an additional wake command can be configured in either of the following files:: $XDG_CONFIG_HOME/joystickwake/joystickwake.conf $HOME/.config/joystickwake/joystickwake.conf This example illustrates the configuration file format and settings:: command = xdg-screensaver reset # This might work on some desktops cooldown = 30 # Number of seconds between wakes loglevel = warning # Also: debug, info, error, critical Command line options ``--command``, ``--cooldown``, and ``--loglevel`` will override their corresponding config file settings. Troubleshooting --------------- The first step in diagnosing a problem is to set joystickwake's log level to ``debug``, restart it, and look for unexpected messages when the problem occurs. Running it in a terminal window may help with log visibility. Note: Joystickwake cannot see your screen, and therefore has no way to be certain of its state. A success message in the log means only that a wake command ran without reporting an error. That is a good sign, but not always sufficient to wake the screen, especially if that wake command was designed for a screen blanker other than the one in use. Every log message begins with "joystickwake" and a timestamp, so if you see text in some other format, it comes from another program. For example, one of the wake commands might print a message about being unable to find its screen saver, or your shell's command-not-found script might offer suggestions when joystickwake probes for a command that is not installed on your system. Both of these examples are harmless, and should appear only a few times, since joystickwake avoids commands that keep failing after a few tries. If joystickwake keeps the screen awake even when all joysticks are idle, it is likely due to a worn joystick sensor constantly reporting movement. This is is unfortunately common in game controllers, and related to the "stick drift" problem that is well known in console gaming. It can usually be resolved by repairing or replacing the hardware, disconnecting it when not in use, or calibrating the dead zones used by the linux joystick and evdev APIs as described here: https://wiki.archlinux.org/title/Gamepad#Setting_up_deadzones_and_calibration Calibration tips: - Joystickwake uses the joystick API by default, so it's best to calibrate that first. Consider calibrating evdev only if required for a particular device. - The ``jstest-gtk`` tool is probably the easiest way to calibrate the joystick API. See the above wiki page for guidance. - If calibrating evdev, focus on the *fuzz* setting. The *flatness* or *deadzone* setting may have no effect at all. - Calibration settings reset when the operating system is restarted. To make them persist, they must be saved in a file and then re-applied whenever the system starts or the game controller is reconnected. This is best done with a udev rule. Debian-based systems offer a joystick package that will automatically restore calibration settings once they have been saved by running ``sudo jscal-store``. The same can be achieved on other distros using the information given here: https://sourceforge.net/p/linuxconsole/code/ci/master/tree/README If the screen wakes with joystick input, but never blanks again even after all joysticks are disconnected, it is possible that one of the wake commands detected on your system is communicating incorrectly with your screen blanker. For example, one user experienced this in a modern GNOME Shell after installing a legacy gnome-screensaver package. In that case, the solution was to remove packages intended for old or unused screen blankers, and rely on Joystickwake's D-Bus idle inhibitor instead. Switching from GNOME's Wayland session to X11 might also help in some cases. If the log shows at least one waker succeeding but the screen still blanks while a joystick is in use, your screen blanker's timeout might be too short for joystickwake. If so, increasing that timeout or decreasing joystickwake's ``cooldown`` setting (making it the shorter of the two) will help. Alternatively, it is possible that multiple screen blankers are running, such as a graphical screen saver and a power manager, with joystickwake only knowing how to wake one of them. The solution is to find a command that will wake the problematic one, make sure that command is installed, and if joystickwake doesn't recognize it by default, configure it as a custom command. If all wakers fail and the screen still blanks, the solution is the same as above: Identify your desktop's screen blanker, install a command that will wake it, and (if necessary) configure joystickwake to use it. Custom Wake Commands -------------------- If none of joystickwake's preconfigured commands wake the screen in a particular desktop environment, finding one that does can require some effort. Asking community members who use the same environment might yield a helpful answer. Once you know the name of the component that blanks the screen, consult its documentation to see if it has a command line tool for controlling it. It may also be worthwhile to query your linux distribution's package manger to see if such a tool was installed along with the screen blanker. After identifying a command that might work, the next step is to test it, preferably without joystickwake running. The simplest way is to set your screen blanker to use a short timeout (e.g. one minute), run the command preceded by a ``sleep`` delay longer than the blanker's timeout, and let your system sit idle to see if it works. For example, this command line does it with a 77 second delay:: sleep 77; my-cmd --wake If the screen blanks as expected and then wakes after the sleep delay, the command will probably work with joystickwake. You can try it in a terminal window, like so:: joystickwake --loglevel debug --cooldown 2 --command "my-cmd --wake" If joystickwake logs a "custom waker failed" message, it means the custom command either produced an error or could not be executed. If pressing a joystick button wakes the screen and logs a "custom waker succeeded" message, then the command works, and can be saved in the configuration file for future login sessions. When run in a terminal window, Control+C will tell joystickwake to quit. The xdg-screensaver tool might work as a custom wake command in some environments:: xdg-screensaver reset Users of KDE Plasma with XWayland might find that joystickwake's preconfigured commands do not suppress the screen energy saving feature. This appears to be a bug in KDE's SimulateUserActivity implementation, reported as bug #440882. A KDE maintainer stated in that report that XWayland is not supported, so the bug seems unlikely to be fixed, but the following custom wake command might be an effective workaround:: qdbus org.kde.Solid.PowerManagement /org/kde/Solid/PowerManagement wakeup Manual Installation ------------------- On linux distributions that do not offer a joystickwake package, it can be installed manually. Note that it is a self-contained single-file Python program, and thus does not have a build step or require additional files. 1. Copy the ``joystickwake`` file to a directory listed in the user's ``PATH`` environment variable, such as ``/usr/local/bin`` or ``$HOME/bin``. 2. Grant execute permission on that copy. To have a desktop environment automatically launch joystickwake at login time, copy ``joystickwake.desktop`` to ``/etc/xdg/autostart`` or ``$HOME/.config/autostart``. The same effect can usually be had with a window manager that does not support .desktop file autostart, but the method depends on the GUI startup system in use, so general instructions cannot be given here. (Hint: It may involve a GUI session startup script, like ``$HOME/.xinitrc`` if using ``startx``.) Joystickwake can be added to a desktop environment's applications menu by copying ``joystickwake.desktop`` to ``/usr/share/applications`` or ``$HOME/.local/share/applications``. This is not recommended, since it can lead users to accidentally launch multiple joystickwake instances, and since automatic launching at login time is usually more convenient. Ensure the dependencies are installed before attempting to launch joystickwake. Python Dependencies ------------------- - pyudev (Debian: python3-pyudev) https://github.com/pyudev/pyudev - dbus-fast (Debian: python3-dbus-fast) https://github.com/Bluetooth-Devices/dbus-fast - Optional, but needed on some desktops to inhibit idle state via D-Bus. - python-xlib (Debian: python3-xlib) https://github.com/python-xlib/python-xlib - Optional, to make joystickwake quit with the X11 session instead of when the parent process exits. See Also -------- - Project page: https://codeberg.org/forestix/joystickwake - Ubuntu package: https://launchpad.net/~foresto/+archive/ubuntu/toys - GameMode can inhibit the screensaver on desktops that implement the org.freedesktop.ScreenSaver D-Bus interface: https://github.com/FeralInteractive/gamemode - xdg-screensaver (from xdg-utils, aka Portland) attempts to be a unified screensaver control interface: https://www.freedesktop.org/wiki/Software/xdg-utils/ - Caffeine runs ``xdg-screensaver suspend`` when it finds a fullscreen window: https://code.launchpad.net/caffeine - Faux GNOME Screensaver is a GNOME compatibility layer for XScreenSaver: https://github.com/jefferyto/faux-gnome-screensaver joystickwake/joystickwake000077500000000000000000000627141522623234400162370ustar00rootroot00000000000000#!/usr/bin/env python3 """ A joystick-aware screen waker. Copyright 2014, 2016-2026 Forest This software is distributed under the terms of the Expat License. See the LICENSE file in the original source distribution for details. """ __version__ = '0.5.4' __version_info__ = tuple(int(n) for n in __version__.split('.')) import argparse import asyncio from collections import namedtuple import configparser import contextlib import ctypes import errno import functools import io import itertools import logging import os import os.path import re import signal import sys import time try: import dbus_fast import dbus_fast.aio except ImportError: pass import pyudev try: import Xlib.display import Xlib.error except ImportError: pass PROGRAM_NAME = os.path.basename(sys.argv[0]) WAKE_REASON = "Active joystick" # ====================================================================== class Waker: """Base class for screen wakers. Subclasses are expected to implement the wake() method, and set self.failed if wake() fails, so calling code will know to skip it next time. The wake() method will be called periodically, like a heartbeat, when user activity is detected. It should relay this heartbeat to the screen blanker using whatever means it can, such as running a command line tool or sending a dbus message. """ #pylint:disable=too-few-public-methods def __init__(self): """Initialize the screen waker. """ self.failed = False # Subclasses must set this to True if they fail. self._log = logging.getLogger('waker') async def wake(self): """Wake the screen. Set self.failed on failure.""" raise NotImplementedError class ExecWaker(Waker): """A subprocess-based screen waker. """ def __init__(self, *args, shellcmd=None, regex=None, name=None): """Initialize the screen waker. :param args: A sequence of program agruments for passing to asyncio.create_subprocess_exec(). :param shellcmd: A command line string for passing to asyncio.create_subprocess_shell(). This can be used instead of `args`. :param regex: A regular expression indicating a failure when it matches a command's stderr output. (Mainly for xset, which has no useful exit status.) :param name: A name for this waker. """ super().__init__() if args and shellcmd: raise ValueError("Both args and shellcmd were specified.") if not (args or shellcmd): raise ValueError("Neither args nor shellcmd were specified.") self._args = args self._shellcmd = shellcmd self._errexpr = re.compile(regex) if regex else None self._name = name if not self._name: self._name = args[0] if args else shellcmd.split()[0] # A command that starts but exits with an error might mean that the # screensaver simply isn't running yet, so we retry a few times. self._softfailcount = 0 async def wake(self): """Wake the screen by running a program. """ self._log.debug("%s executing: %s", self, ' '.join(self._args) if self._args else self._shellcmd) try: if self._args: #pylint:disable=no-value-for-parameter process = await asyncio.create_subprocess_exec( *self._args, stdin=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) else: process = await asyncio.create_subprocess_shell( self._shellcmd, stdin=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) except OSError as error: self.failed = True self._log.info("%s is unavailable: %s", self, error) return outbytes, errbytes = await process.communicate() if self._log.getEffectiveLevel() <= logging.DEBUG: sys.stdout.buffer.write(outbytes) sys.stdout.buffer.flush() # stdout is block-buffered sys.stderr.buffer.write(errbytes) errtext = errbytes.decode('utf-8') errmatch = errtext and self._errexpr and self._errexpr.search(errtext) if process.returncode: reason = f"exit status {process.returncode}" elif errmatch: reason = f"stderr contained {errmatch[0]!r}" else: self._log.debug("%s reported success", self) self._softfailcount = 0 return self._softfailcount += 1 if self._softfailcount < 3: self._log.info("%s failed (try %s): %s", self, self._softfailcount, reason) return self.failed = True self._log.info("%s failed (try %s; giving up): %s", self, self._softfailcount, reason) def __str__(self): """Return a string that identifies this waker. """ return self._name + " waker" class DBusWaker(ExecWaker): """A wrapper class to simplify building dbus-send command lines. """ #pylint:disable=too-few-public-methods def __init__(self, target, *args, desktop=None, name=None): """Initialize the screen waker. :param target: A dot-separated dbus interface & method name. (The service name and object path will be derived from the interface name.) :param args: A sequence of dbus method agruments. :param desktop: Name of a desktop environment to require, or None. This waker will automatically fail unless this is in the XDG_CURRENT_DESKTOP environment variable. Useful for managing behavior differences between different desktops' dbus service implementations. :param name: A name for this waker. """ def encode(arg): if arg is True: return 'boolean:true' if arg is False: return 'boolean:false' return 'string:' + arg # service name, object path, interface, method, args interface, _, _ = target.rpartition('.') path = '/' + interface.replace('.', '/') service = interface args = [encode(a) for a in args] command = [ 'dbus-send', '--type=method_call', '--print-reply', # this option is required for useful exit status '--dest=' + service, path, target] + args super().__init__(*command, name=name) # Determine whether we are in the required desktop environment, but # don't log it yet, since logging has not yet been configured desktops = set(os.environ.get('XDG_CURRENT_DESKTOP', '').split(':')) if desktop and desktop not in desktops: self._nodesktop = desktop else: self._nodesktop = None async def wake(self): # automatically fail if outside the required desktop environment if self._nodesktop: self.failed = True self._log.info("%s is unavailable: requires %s desktop", self, self._nodesktop) return await super().wake() class IdleInhibitor(Waker): """A client for the freedesktop.org Idle Inhibition Service. https://specifications.freedesktop.org/idle-inhibit-spec/latest/re01.html Launches a long-running task to communicate with that service, and exposes a heartbeat-style control interface. """ def __init__(self, cooldown): """Initialize the screen waker. """ super().__init__() self.cooldown = cooldown # hint for calculating inhibition hold time self._timeout = 10 # dbus call timeout, in seconds self._ping = None # worker wake-up Event self._worker = None # task reference to let worker run forever async def wake(self): """Wake the screen by pinging our worker loop. """ if not self._worker: self._log.debug("%s starting worker", self) self._ping = asyncio.Event() self._worker = asyncio.ensure_future(self._work()) self._ping.set() async def _work(self): """Respond to pings by holding an Idle Inhibition Service session. """ # Our inhibition hold time should be longer than the wake cooldown, # but not so long as to unreasonably delay the screen blanker. # (KDE and GNOME restart their idle timers after we stop inhibiting.) holdtime = self.cooldown * 2 busname = 'org.freedesktop.ScreenSaver' buspath = '/org/freedesktop/ScreenSaver' ifacename = busname quickly = functools.partial(asyncio.wait_for, timeout=self._timeout) try: self._log.debug("%s connecting to the session bus", self) bus = await quickly(dbus_fast.aio.MessageBus().connect()) introspection = await quickly(bus.introspect(busname, buspath)) proxy = bus.get_proxy_object(busname, buspath, introspection) iface = proxy.get_interface(ifacename) self._log.debug("%s riding the bus as %s", self, bus.unique_name) cookie = None while True: try: await asyncio.wait_for(self._ping.wait(), timeout=None if cookie is None else holdtime) self._ping.clear() except asyncio.TimeoutError: self._log.debug("%s releasing inhibition", self) await quickly(iface.call_un_inhibit(cookie)) cookie = None self._log.debug("%s standing by", self) else: if cookie is None: self._log.debug("%s requesting inhibition", self) cookie = await quickly( iface.call_inhibit(PROGRAM_NAME, WAKE_REASON)) self._log.debug("%s inhibiting for %ds", self, holdtime) except NameError: self._log.info("%s is unavailable: needs python3-dbus-fast", self) except ( dbus_fast.DBusError, dbus_fast.InterfaceNotFoundError, ) as err: self._log.info("%s is unavailable: %s", self, err) except asyncio.TimeoutError: self._log.warning("%s failed: dbus call timed out", self) except Exception as err: #pylint:disable=broad-except self._log.info("%s failed: %s", self, repr(err)) self.failed = True def __str__(self): """Return a string that identifies this waker. """ return "Idle Inhibitor" # ====================================================================== class JoystickWatcher: """A joystick activity monitor. """ #pylint:disable=too-few-public-methods #pylint:disable=too-many-instance-attributes def __init__(self, wakers=(), cooldown=10, useevdev=False): """Initialize internal data. :param wakers: An iterable of Waker instances to call when joystick activity is detected. :param cooldown: Minimum number of seconds between calling wakers. :param useevdev: True to prefer /dev/input/event* over /dev/input/js* when a joystick has both. (The latter is the older joystick device interface. It is less chatty than evdev, making it the better choice for minimal I/O processing.) Monitoring will begin when start() is called and the event loop runs. """ self._wakers = list(wakers) self._waking = None # a Task reference to let wake routines complete self._cooldown = cooldown self._last_wake = 0 # time when we last woke the screen self._best_devname_prefix = 'event' if useevdev else 'js' self._log = logging.getLogger('watcher') # The evdev & joydev nodes for the same joystick will share a parent, # so mapping devices by their parent lets us avoid watching both. self._devinfo_by_parent = {} # device parent -> (name, file descriptor) self._context = pyudev.Context() self._monitor = pyudev.Monitor.from_netlink(self._context) self._monitor.filter_by(subsystem='input') async def start(self): """Find existing joysticks and add device monitors to the event loop. """ if not self._wakers: self._log.error("exiting because no wakers are configured") sys.exit(1) self._watch_known_joysticks() self._monitor.start() asyncio.get_event_loop().add_reader( self._monitor.fileno(), self._poll_udev) def _watch_known_joysticks(self): """Start watching known joystick devices for activity. """ for device in self._context.list_devices(subsystem='input', ID_INPUT_JOYSTICK=True): self._watch_device(device) def _poll_udev(self): """Poll udev for an event, and handle it. This is called when the udev monitor's file descriptor becomes readable. """ device = self._monitor.poll() if device.action == 'add': self._watch_device(device) elif device.action == 'remove': self._forget_device(device) @staticmethod def _is_joystick(device): """Return True if a udev device represents a joystick. """ jsproperty = device.get('ID_INPUT_JOYSTICK') return bool(jsproperty and jsproperty != '0' and device.device_node) @staticmethod def _get_device_description(device): """Return a human-readable description of a udev device. """ props = device.properties try: desc = f"{props['ID_VENDOR']} {props['ID_MODEL']}" except KeyError: desc = device.get('NAME') or device.parent.get('NAME') or '""' # Extra info, in case of multiple devices or missing vendor/description extra = [] try: extra.append(props['ID_BUS']) except KeyError: pass try: if device.device_path.split('/')[2] == 'virtual': extra.append('virtual') except KeyError: pass if extra: desc += f" ({' '.join(extra)})" return desc @staticmethod def _get_device_dump(device, prefix=' '): lines = [] for attr in (''' device_node device_number device_path device_type driver subsystem sys_name sys_number sys_path is_initialized time_since_initialized action sequence_number '''.split()): lines.append(f"{prefix}{attr}: {getattr(device, attr)}") for link in device.device_links: lines.append(f"{prefix}link: {link}") for tag in device.tags: lines.append(f"{prefix}tag: {tag}") for prop in device.properties: lines.append(f"{prefix}prop {prop}: {device.properties[prop]}") try: for attr in device.attributes.available_attributes: lines.append( f"{prefix}sysfs {attr}: {device.attributes.get(attr)}") except AttributeError: pass return '\n'.join(lines) def _watch_device(self, device): """Start watching a udev device for activity, if it is a joystick. """ if not self._is_joystick(device): return # If the device is no more useful than one we already watch, skip it. olddevinfo = self._devinfo_by_parent.get(device.parent) if olddevinfo: if (olddevinfo.name.startswith(self._best_devname_prefix) or not device.sys_name.startswith(self._best_devname_prefix)): return # Open the device try: newdevinfo = namedtuple('DevInfo', 'name fd')(name=device.sys_name, fd=os.open(device.device_node, os.O_RDONLY | os.O_NONBLOCK)) except PermissionError: self._log.error("permission denied on open %s", device.device_node) return except FileNotFoundError: self._log.error("udev error: %s is missing!", device.device_node) self._log.error("udev %s details:\n%s", device.sys_name, self._get_device_dump(device)) for i, ancestor in enumerate(device.ancestors, start=1): self._log.error("udev %s ancestor %d details:\n%s", device.sys_name, i, self._get_device_dump(ancestor)) return # Now that we're sure we can use the device, close any old one. if olddevinfo: asyncio.get_event_loop().remove_reader(olddevinfo.fd) os.close(olddevinfo.fd) self._log.info("%s discarded in favor of its twin: %s", olddevinfo.name, newdevinfo.name) asyncio.get_event_loop().add_reader( newdevinfo.fd, self._read_fd, newdevinfo) self._devinfo_by_parent[device.parent] = newdevinfo self._log.info("%s is a joystick: %s", device.sys_name, self._get_device_description(device)) def _forget_device(self, device): """Stop watching a udev device for activity. """ devinfo = self._devinfo_by_parent.get(device.parent) if not devinfo: return if devinfo.name != device.sys_name: # We're watching a sibling device file; no need to forget this one. return del self._devinfo_by_parent[device.parent] asyncio.get_event_loop().remove_reader(devinfo.fd) try: os.close(devinfo.fd) except OSError as error: if error.errno != errno.ENODEV: raise self._log.info("%s removed from the system", device.sys_name) def _read_fd(self, devinfo): """Read data from a device. This is called when a device node's file descriptor becomes readable. """ # Read enough for many evdev events (analog sticks can produce a lot). try: os.read(devinfo.fd, 960) self._log.debug("%s activity", devinfo.name) self._wake_screen() except OSError as error: if error.errno != errno.ENODEV: raise asyncio.get_event_loop().remove_reader(devinfo.fd) def _wake_screen(self): """Wake the screen if we haven't done so recently. """ now = time.monotonic() if now - self._last_wake < self._cooldown: return self._last_wake = now # Discard any wakers that failed last time. self._wakers = [waker for waker in self._wakers if not waker.failed] if not self._wakers: self._log.error("exiting because all wakers have failed") sys.exit(1) self._log.info("waking the screen") wakeroutines = [waker.wake() for waker in self._wakers] self._waking = asyncio.ensure_future(asyncio.gather(*wakeroutines)) # ====================================================================== DEFAULT_WAKERS = [ ExecWaker('xset', 'dpms', 'force', 'on', regex=".+", name="X11 DPMS"), ExecWaker('xset', 's', 'reset', regex=".+", name="X11"), ExecWaker('xscreensaver-command', '-deactivate', name="XScreenSaver"), ExecWaker('gnome-screensaver-command', '--deactivate', name="GNOME Screensaver"), ExecWaker('mate-screensaver-command', '--poke', name="MATE"), ExecWaker('xfce4-screensaver-command', '--poke', name="Xfce"), DBusWaker('org.freedesktop.ScreenSaver.SimulateUserActivity', desktop='KDE', name="KDE"), DBusWaker('org.freedesktop.ScreenSaver.Inhibit', PROGRAM_NAME, WAKE_REASON, desktop='GNOME', name="heartbeat Inhibit"), DBusWaker('org.gnome.ScreenSaver.SetActive', False, desktop='GNOME', name="GNOME SetActive"), ] class Configuration: """Configurable program settings. """ #pylint:disable=too-few-public-methods loglevel = 'warning' cooldown = 30 # minimum number of seconds between screen wakes interval = 0 # deprecated since v0.4 (renamed to cooldown) command = None # custom screen waker shell command, as a string inhibit = True # hidden option for disabling the Idle Inhibitor def parse_command_line(config): """Apply command line options to the global configuration. """ parser = argparse.ArgumentParser( description="Wakes the screen when joysticks are active.") parser.add_argument('--version', action='version', version=__version__) parser.add_argument('--loglevel', choices='debug info warning error critical'.split()) parser.add_argument('--cooldown', type=int, metavar='SECONDS') parser.add_argument('--interval', type=int, help=argparse.SUPPRESS) parser.add_argument('--command') parser.add_argument('--noinhibit', dest='inhibit', action='store_false', help=argparse.SUPPRESS) parser.parse_args(namespace=config) def load_config_file(config): """Apply configuration file options to the global configuration. """ indir = os.environ.get('XDG_CONFIG_HOME') or os.path.expanduser("~/.config") path = os.path.join(indir, PROGRAM_NAME, PROGRAM_NAME + ".conf") if not os.path.exists(path): return parser = configparser.ConfigParser(interpolation=None, inline_comment_prefixes=('#',)) with open(path, encoding='utf-8') as stream: # Simulate a config file section header, to please ConfigParser: lines = itertools.chain(("[top]",), stream) parser.read_file(lines) for name, value in parser['top'].items(): if isinstance(getattr(config, name), bool): value = value.lower() in {'true', 't', 'yes', 'y', 'on', '1'} elif isinstance(getattr(config, name), int): value = int(value) setattr(config, name, value) def init_logging(config): """Configure logging. """ logging.getLogger('asyncio').setLevel(logging.WARNING) logger = logging.getLogger() level = getattr(logging, config.loglevel.upper()) logger.setLevel(level) handler = logging.StreamHandler(sys.stderr) if level <= logging.DEBUG: msgformat = PROGRAM_NAME + " [{asctime}] {levelname} {message}" else: msgformat = PROGRAM_NAME + " [{asctime}] {message}" formatter = logging.Formatter(msgformat, datefmt="%H:%M:%S", style='{') handler.setFormatter(formatter) logger.addHandler(handler) def stop_loop_when_xserver_quits(log, loop): """Call loop.stop() when a connection to the X Server is lost. This is how we exit when Xlib is available. :param log: A logging.Logger object. :param loop: An asyncio event loop. On success, return True. If Xlib or the display are unavailable, log a message and return False. """ try: with contextlib.redirect_stdout(io.StringIO()): # silence Xlib display = Xlib.display.Display() except NameError: log.info("cannot contact display server: python3-xlib is not installed") return False except Xlib.error.DisplayError as error: if 'DISPLAY' not in os.environ: log.info("cannot contact display server: DISPLAY var is not set") else: log.info("%s", error) return False log.info("connected to the display server") def read_display_event(): """Drain the incoming X message queue.""" try: while display.pending_events(): display.next_event() except Xlib.error.ConnectionClosedError: log.debug("lost connection to the display server") loop.stop() loop.add_reader(display.fileno(), read_display_event) return True def exit_after_parent(): """Request SIGHUP when our parent process exits. This is how we exit when the user logs out if we can't use Xlib. """ libc = ctypes.CDLL('libc.so.6') pr_set_pdeathsig = 1 # value from libc.prctl(pr_set_pdeathsig, signal.SIGHUP) # man prctl(2) for details def main(): """Get things running. """ config = Configuration() load_config_file(config) parse_command_line(config) init_logging(config) log = logging.getLogger() if getattr(config, 'interval'): log.warning("'interval' option is deprecated; use 'cooldown' instead") config.cooldown = config.interval loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) if not stop_loop_when_xserver_quits(log, loop): log.info("will exit when our parent process ends") exit_after_parent() wakers = list(DEFAULT_WAKERS) if config.command: wakers.append(ExecWaker(shellcmd=config.command, name="custom")) if config.inhibit: wakers.append(IdleInhibitor(cooldown=config.cooldown)) try: watcher = JoystickWatcher(wakers=wakers, cooldown=config.cooldown) loop.run_until_complete(watcher.start()) loop.run_forever() except KeyboardInterrupt: log.info("exiting at user request") if __name__ == '__main__': main() joystickwake/joystickwake.desktop000066400000000000000000000002311522623234400176660ustar00rootroot00000000000000[Desktop Entry] Type=Application Name=Joystickwake Comment=Joystick-aware screen waker Icon=input-gaming NoDisplay=true Exec=joystickwake Terminal=false joystickwake/setup.py000066400000000000000000000033231522623234400153030ustar00rootroot00000000000000#!/usr/bin/env python3 """ Distutils installation script. """ import ast import os.path try: from setuptools import setup except ImportError: from distutils.core import setup def read_version_string(filename): """Reads the __version__ string from a python source file. """ path = os.path.join(os.path.dirname(__file__), filename) with open(path, encoding='utf-8') as stream: for line in stream: if line.startswith('__version__'): return ast.parse(line).body[0].value.value return None def read_readme(filename): """Reads a long_description string from a text file. """ path = os.path.join(os.path.dirname(__file__), filename) return open(path, encoding='utf-8').read() setup( name='joystickwake', version=read_version_string('joystickwake'), description="Joystick-aware screen waker", long_description=read_readme('README.rst'), author='Forest', author_email='forestix@kcat.cc', url='https://codeberg.org/forestix/joystickwake', python_requires='>=3.8', requires=['pyudev'], install_requires=['pyudev'], # for pip scripts=['joystickwake'], data_files=[('/etc/xdg/autostart', ['joystickwake.desktop'])], platforms=['Linux'], license='MIT', classifiers=[ 'Development Status :: 5 - Production/Stable', 'Environment :: No Input/Output (Daemon)', 'Intended Audience :: End Users/Desktop', 'Operating System :: POSIX :: Linux', 'Programming Language :: Python', 'Programming Language :: Python :: 3', 'Topic :: Desktop Environment :: Screen Savers', 'Topic :: Games/Entertainment', 'Topic :: System :: Hardware', ], )