diff --git a/README.md b/README.md index de5a07e..db11fd8 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,13 @@ -# tablet-mode +# Chuwi Minibook tablet mode switch -Allow users to toggle a convertible laptop between laptop and tablet mode. +Allow users to toggle a Chuwi Minibook laptop between laptop and tablet mode. +This is based on https://github.com/conqp/tablet-mode. -## Configuration +## Installation -The keyboard device to be deactivated in tablet mode must be specified in `/etc/tablet-mode.conf`: +Just run `install.sh` on your Debian system and enter the name of you local user account. +Reboot after successful installation. - [Devices] - keyboard = /dev/input/by-path/platform-i8042-serio-0-event-kbd - touchpad = /dev/input/by-path/platform-i8042-serio-1-event-mouse - ## Usage -You must be a member of the group `tablet` to toggle between tablet and laptop mode. -You can toggle between tablet and laptop mode by running `toggle-tablet-mode` or use the desktop icon provided with this package. +You can toggle between tablet and laptop mode by running `setsysmode toggle` or use the desktop icon provided with this package. diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..990b345 --- /dev/null +++ b/install.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash + +set -eu + +GROUP=tablet + +if ! [ $(getent group "$GROUP") ]; then + echo "Add tablet group..." + /usr/sbin/groupadd tablet +fi + +read -p "Enter your local username: " user + +if ! [ $(groupmems -g "$GROUP" -l | grep "$user" ) ]; then + echo "Add user to group..." + usermod -a -G tablet "$user" +fi + +echo "Copy files..." +cp -r tabletmode/ /usr/local/lib/python3.11/dist-packages +cp tablet-mode.service /etc/systemd/system +cp laptop-mode.service /etc/systemd/system +cp tablet-mode.json /etc/ +cp tablet-mode.desktop /home/"$user"/.local/applications +cp tablet-mode.sudoers /etc/sudoers.d/tablet-mode +cp setsysmode /usr/local/bin +chmod +x /usr/local/bin/setsysmode +cp sysmoded /usr/local/bin +chmod +x /usr/local/bin/sysmoded + +echo "Reload systemd..." +systemctl daemon-reload + +echo "Install packages..." +apt install evtest -y diff --git a/laptop-mode.service b/laptop-mode.service new file mode 100644 index 0000000..93e4f27 --- /dev/null +++ b/laptop-mode.service @@ -0,0 +1,10 @@ +[Unit] +Description=Configure system for laptop mode +Conflicts=tablet-mode.service + +[Service] +ExecStart=/usr/local/bin/sysmoded laptop +StandardOutput=null + +[Install] +WantedBy=multi-user.target diff --git a/setsysmode b/setsysmode new file mode 100644 index 0000000..fb31d1f --- /dev/null +++ b/setsysmode @@ -0,0 +1,8 @@ +#! /usr/bin/env python3 +"""Sets the system mode.""" + +from tabletmode.cli import main + + +if __name__ == '__main__': + main() diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..51f156a --- /dev/null +++ b/setup.py @@ -0,0 +1,25 @@ +#! /usr/bin/env python + +from setuptools import setup + +setup( + name='tabletmode', + use_scm_version=True, + setup_requires=['setuptools_scm'], + author='Richard Neumann', + author_email='mail@richard-neumann.de', + python_requires='>=3.8', + packages=['tabletmode'], + entry_points={ + 'console_scripts': [ + 'setsysmode = tabletmode.cli:main', + 'sysmoded = tabletmode.daemon:main' + ], + }, + url='https://github.com/conqp/tablet-mode', + license='GPLv3', + description='Tablet mode switch for GNOME 3.', + long_description=open('README.md').read(), + long_description_content_type="text/markdown", + keywords='tablet mode tent convertible switch' +) diff --git a/sysmoded b/sysmoded new file mode 100644 index 0000000..347aca9 --- /dev/null +++ b/sysmoded @@ -0,0 +1,8 @@ +#! /usr/bin/env python3 +"""System mode daemon.""" + +from tabletmode.daemon import main + + +if __name__ == '__main__': + main() diff --git a/tablet-mode b/tablet-mode deleted file mode 100755 index d4b5c82..0000000 --- a/tablet-mode +++ /dev/null @@ -1,48 +0,0 @@ -#! /usr/bin/env python3 -"""Grabs the respective device to discard any input from it.""" - -from configparser import ConfigParser -from contextlib import suppress -from subprocess import run -from threading import Thread - - -CONFIG_FILE = '/etc/tablet-mode.conf' -EVTEST = '/usr/bin/evtest' - - -def grab_device(device): - """Grabs the respective device via evtest.""" - - return run((EVTEST, '--grab', device)) - - -def get_devices(): - """Reads the device from the config file.""" - - parser = ConfigParser() - parser.read(CONFIG_FILE) - - with suppress(KeyError): - yield parser['Devices']['keyboard'] - - with suppress(KeyError): - yield parser['Devices']['touchpad'] - - -def disable_devices(devices): - """Disables the given devices.""" - - threads = [] - - for device in devices: - thread = Thread(target=grab_device, args=[device]) - threads.append(thread) - thread.start() - - for thread in threads: - thread.join() - - -if __name__ == '__main__': - disable_devices(get_devices()) diff --git a/tablet-mode.desktop b/tablet-mode.desktop index 1d45f12..2c3a375 100644 --- a/tablet-mode.desktop +++ b/tablet-mode.desktop @@ -2,6 +2,6 @@ Comment=Toggle tablet mode Terminal=false Name=Tablet Mode -Exec=/usr/bin/toggle-tablet-mode +Exec=/usr/local/bin/setsysmode toggle Type=Application Icon=pda-symbolic diff --git a/tablet-mode.json b/tablet-mode.json new file mode 100644 index 0000000..14f657e --- /dev/null +++ b/tablet-mode.json @@ -0,0 +1,7 @@ +{ + "tablet": [ + "/dev/input/by-path/platform-i8042-serio-0-event-kbd", + "/dev/input/by-path/pci-0000:00:14.0-usb-0:9:1.0-event-mouse" + ], + "notify": false +} diff --git a/tablet-mode.service b/tablet-mode.service index 951fb4a..fc893e7 100644 --- a/tablet-mode.service +++ b/tablet-mode.service @@ -1,6 +1,10 @@ [Unit] -Description=Disable keyboard and touchpad for tablet mode +Description=Configure system for tablet mode +Conflicts=laptop-mode.service [Service] -ExecStart=/usr/bin/tablet-mode +ExecStart=/usr/local/bin/sysmoded tablet StandardOutput=null + +[Install] +WantedBy=multi-user.target diff --git a/tablet-mode.sudoers b/tablet-mode.sudoers index c49c8f5..e591d1d 100644 --- a/tablet-mode.sudoers +++ b/tablet-mode.sudoers @@ -1 +1,13 @@ -%tablet ALL=(ALL) NOPASSWD: /usr/bin/systemctl start tablet-mode.service, /usr/bin/systemctl stop tablet-mode.service +# This file is part of tablet-mode. +# +# It allows users in the group "tablet" to toggle +# the system between tablet and laptop mode. +# +################################################################################ + +Cmnd_Alias START_LAPTOP_MODE = /usr/bin/systemctl start laptop-mode.service +Cmnd_Alias STOP_LAPTOP_MODE = /usr/bin/systemctl stop laptop-mode.service +Cmnd_Alias START_TABLET_MODE = /usr/bin/systemctl start tablet-mode.service +Cmnd_Alias STOP_TABLET_MODE = /usr/bin/systemctl stop tablet-mode.service + +%tablet ALL=(ALL) NOPASSWD: START_LAPTOP_MODE, STOP_LAPTOP_MODE, START_TABLET_MODE, STOP_TABLET_MODE diff --git a/tabletmode/__init__.py b/tabletmode/__init__.py new file mode 100644 index 0000000..a6deee4 --- /dev/null +++ b/tabletmode/__init__.py @@ -0,0 +1 @@ +"""Tablet mode library.""" diff --git a/tabletmode/cli.py b/tabletmode/cli.py new file mode 100644 index 0000000..c20e898 --- /dev/null +++ b/tabletmode/cli.py @@ -0,0 +1,130 @@ +"""Sets the system mode.""" + +from argparse import ArgumentParser, Namespace +from subprocess import DEVNULL +from subprocess import CalledProcessError +from subprocess import CompletedProcess +from subprocess import check_call +from subprocess import run +from sys import stderr +from typing import Optional + +from tabletmode.config import load_config + + +DESCRIPTION = 'Sets or toggles the system mode.' +LAPTOP_MODE_SERVICE = 'laptop-mode.service' +TABLET_MODE_SERVICE = 'tablet-mode.service' +SUDO = '/usr/bin/sudo' + + +def get_args() -> Namespace: + """Returns the CLI arguments.""" + + parser = ArgumentParser(description=DESCRIPTION) + parser.add_argument( + '-n', '--notify', action='store_true', + help='display an on-screen notification') + subparsers = parser.add_subparsers(dest='mode') + subparsers.add_parser('toggle', help='toggles the system mode') + subparsers.add_parser('laptop', help='switch to laptop mode') + subparsers.add_parser('tablet', help='switch to tablet mode') + subparsers.add_parser('default', help='do not disable any input devices') + return parser.parse_args() + + +def systemctl(action: str, unit: str, *, root: bool = False, + sudo: str = SUDO) -> bool: + """Runs systemctl.""" + + command = [sudo] if root else [] + command += ['systemctl', action, unit] + + try: + check_call(command, stdout=DEVNULL) # Return 0 on success. + except CalledProcessError: + return False + + return True + + +def notify_send(summary: str, body: Optional[str] = None) -> CompletedProcess: + """Sends the respective message.""" + + command = ['/usr/bin/notify-send', summary] + + if body is not None: + command.append(body) + + return run(command, stdout=DEVNULL, check=False) + + +def notify_laptop_mode() -> CompletedProcess: + """Notifies about laptop mode.""" + + return notify_send('Laptop mode.', 'The system is now in laptop mode.') + + +def notify_tablet_mode() -> CompletedProcess: + """Notifies about tablet mode.""" + + return notify_send('Tablet mode.', 'The system is now in tablet mode.') + + +def default_mode(notify: bool = False, *, sudo: str = SUDO) -> None: + """Restores all blocked input devices.""" + + systemctl('stop', LAPTOP_MODE_SERVICE, root=True, sudo=sudo) + systemctl('stop', TABLET_MODE_SERVICE, root=True, sudo=sudo) + + if notify: + notify_send('Default mode.', 'The system is now in default mode.') + + +def laptop_mode(notify: bool = False, *, sudo: str = SUDO) -> None: + """Starts the laptop mode.""" + + systemctl('stop', TABLET_MODE_SERVICE, root=True, sudo=sudo) + systemctl('start', LAPTOP_MODE_SERVICE, root=True, sudo=sudo) + + if notify: + notify_laptop_mode() + + +def tablet_mode(notify: bool = False, *, sudo: str = SUDO) -> None: + """Starts the tablet mode.""" + + systemctl('stop', LAPTOP_MODE_SERVICE, root=True, sudo=sudo) + systemctl('start', TABLET_MODE_SERVICE, root=True, sudo=sudo) + + if notify: + notify_tablet_mode() + + +def toggle_mode(notify: bool = False, *, sudo: str = SUDO) -> None: + """Toggles between laptop and tablet mode.""" + + if systemctl('status', TABLET_MODE_SERVICE): + laptop_mode(notify=notify, sudo=sudo) + else: + tablet_mode(notify=notify, sudo=sudo) + + +def main() -> None: + """Runs the main program.""" + + args = get_args() + config = load_config() + notify = config.get('notify', False) or args.notify + sudo = config.get('sudo', SUDO) + + if args.mode == 'toggle': + toggle_mode(notify=notify, sudo=sudo) + elif args.mode == 'default': + default_mode(notify=notify, sudo=sudo) + elif args.mode == 'laptop': + laptop_mode(notify=notify, sudo=sudo) + elif args.mode == 'tablet': + tablet_mode(notify=notify, sudo=sudo) + else: + print('Must specify a mode.', file=stderr, flush=True) diff --git a/tabletmode/config.py b/tabletmode/config.py new file mode 100644 index 0000000..8c6c928 --- /dev/null +++ b/tabletmode/config.py @@ -0,0 +1,23 @@ +"""Configuration file parsing.""" + +from json import load +from logging import getLogger +from pathlib import Path + + +__all__ = ['load_config'] + + +CONFIG_FILE = Path('/etc/tablet-mode.json') +LOGGER = getLogger('tabletmode') + + +def load_config() -> dict: + """Returns the configuration.""" + + try: + with CONFIG_FILE.open('r') as cfg: + return load(cfg) + except FileNotFoundError: + LOGGER.warning('Config file %s does not exist.', CONFIG_FILE) + return {} diff --git a/tabletmode/daemon.py b/tabletmode/daemon.py new file mode 100644 index 0000000..0632be1 --- /dev/null +++ b/tabletmode/daemon.py @@ -0,0 +1,68 @@ +"""System mode daemon.""" + +from argparse import ArgumentParser, Namespace +from logging import DEBUG, INFO, basicConfig, getLogger +from subprocess import Popen +from typing import Iterable + +from tabletmode.config import load_config + + +DESCRIPTION = 'Setup system for laptop or tablet mode.' +EVTEST = '/usr/bin/evtest' +LOG_FORMAT = '[%(levelname)s] %(name)s: %(message)s' +LOGGER = getLogger('sysmoded') + + +def get_args() -> Namespace: + """Parses the CLI arguments.""" + + parser = ArgumentParser(description=DESCRIPTION) + parser.add_argument( + '-v', '--verbose', action='store_true', + help='turn on verbose logging') + subparsers = parser.add_subparsers(dest='mode') + subparsers.add_parser('laptop', help='enable laptop mode') + subparsers.add_parser('tablet', help='enable tablet mode') + return parser.parse_args() + + +def disable_device(device: str) -> Popen: + """Disables the respective device via evtest.""" + + return Popen((EVTEST, '--grab', device)) + + +def disable_devices(devices: Iterable[str]) -> None: + """Disables the given devices.""" + + subprocesses = [] + + for device in devices: + subprocess = disable_device(device) + subprocesses.append(subprocess) + + for subprocess in subprocesses: + subprocess.wait() + + +def get_devices(mode: str) -> Iterable[str]: + """Reads the device from the config file.""" + + config = load_config() + devices = config.get(mode) or () + + if not devices: + LOGGER.info('No devices configured to disable.') + + return devices + + +def main(): + """Runs the main program.""" + + arguments = get_args() + level = DEBUG if arguments.verbose else INFO + basicConfig(level=level, format=LOG_FORMAT) + devices = get_devices(arguments.mode) + disable_devices(devices) diff --git a/toggle-tablet-mode b/toggle-tablet-mode deleted file mode 100755 index 1c535bd..0000000 --- a/toggle-tablet-mode +++ /dev/null @@ -1,10 +0,0 @@ -#! /bin/bash - -if ( systemctl status tablet-mode.service ); then - sudo systemctl stop tablet-mode.service - notify-send "Laptop mode" "The system is now in laptop mode." -else - sudo systemctl start tablet-mode.service - notify-send "Tablet mode" "The system is now in tablet mode." -fi -