Created
October 10, 2025 08:09
-
-
Save NotKit/3e4a9af7328300631cd7be5fca825205 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/python3 | |
| # -*- coding: utf-8 -*- | |
| # Copyright (C) 2025 TheKit | |
| # SPDX-License-Identifier: GPL-3.0-or-later | |
| """ | |
| Generate libinput quirks for devices with problematic kernel drivers. | |
| On some devices (commonly OnePlus/OPPO/Realme), the touchscreen kernel driver | |
| reports certain absolute axes (ABS_MT_WIDTH_MAJOR, ABS_MT_PRESSURE, etc.) with | |
| min == max values, which causes libinput to reject the device with a "kernel bug" | |
| error. | |
| This script automatically detects such devices and generates a libinput | |
| quirks configuration to disable the problematic axes. | |
| """ | |
| import os | |
| import sys | |
| import glob | |
| import ctypes | |
| import fcntl | |
| import subprocess | |
| from ctypes import c_int, Structure | |
| # ioctl constants | |
| _IOC_NRBITS = 8 | |
| _IOC_TYPEBITS = 8 | |
| _IOC_SIZEBITS = 14 | |
| _IOC_DIRBITS = 2 | |
| _IOC_NRSHIFT = 0 | |
| _IOC_TYPESHIFT = _IOC_NRSHIFT + _IOC_NRBITS | |
| _IOC_SIZESHIFT = _IOC_TYPESHIFT + _IOC_TYPEBITS | |
| _IOC_DIRSHIFT = _IOC_SIZESHIFT + _IOC_SIZEBITS | |
| _IOC_NONE = 0 | |
| _IOC_WRITE = 1 | |
| _IOC_READ = 2 | |
| def _IOC(dir, type, nr, size): | |
| return ( | |
| (dir << _IOC_DIRSHIFT) | | |
| (type << _IOC_TYPESHIFT) | | |
| (nr << _IOC_NRSHIFT) | | |
| (size << _IOC_SIZESHIFT) | |
| ) | |
| def _IOR(type, nr, size): | |
| return _IOC(_IOC_READ, type, nr, size) | |
| # Input event subsystem constants | |
| EV_ABS = 0x03 | |
| # Absolute axis codes we're interested in | |
| ABS_PRESSURE = 0x18 | |
| ABS_MT_TOUCH_MAJOR = 0x30 | |
| ABS_MT_TOUCH_MINOR = 0x31 | |
| ABS_MT_WIDTH_MAJOR = 0x32 | |
| ABS_MT_WIDTH_MINOR = 0x33 | |
| ABS_MT_PRESSURE = 0x3a | |
| # Absolute axis information structure | |
| class input_absinfo(Structure): | |
| _fields_ = [ | |
| ("value", c_int), | |
| ("minimum", c_int), | |
| ("maximum", c_int), | |
| ("fuzz", c_int), | |
| ("flat", c_int), | |
| ("resolution", c_int), | |
| ] | |
| # ioctl for getting absolute axis information | |
| EVIOCGABS = lambda x: _IOR(ord('E'), 0x40 + x, ctypes.sizeof(input_absinfo)) | |
| # ioctl for getting device name | |
| EVIOCGNAME_LEN = 256 | |
| EVIOCGNAME = _IOR(ord('E'), 0x06, EVIOCGNAME_LEN) | |
| # Bit test macros | |
| EVIOCGBIT = lambda ev, len: _IOC(_IOC_READ, ord('E'), 0x20 + ev, len) | |
| def test_bit(bit, array): | |
| """Test if a bit is set in a byte array.""" | |
| return (array[bit // 8] & (1 << (bit % 8))) != 0 | |
| def get_device_name(fd): | |
| """Get the name of an input device.""" | |
| name_buffer = ctypes.create_string_buffer(EVIOCGNAME_LEN) | |
| try: | |
| fcntl.ioctl(fd, EVIOCGNAME, name_buffer) | |
| return name_buffer.value.decode('utf-8', errors='ignore').rstrip('\x00') | |
| except (OSError, IOError): | |
| pass | |
| return None | |
| def get_abs_info(fd, axis): | |
| """Get absolute axis information.""" | |
| absinfo = input_absinfo() | |
| try: | |
| fcntl.ioctl(fd, EVIOCGABS(axis), absinfo) | |
| return absinfo | |
| except (OSError, IOError): | |
| pass | |
| return None | |
| def has_event_type(fd, event_type): | |
| """Check if device supports a given event type.""" | |
| # Allocate enough bytes to hold EV_MAX bits | |
| evbits = ctypes.create_string_buffer(32) | |
| try: | |
| fcntl.ioctl(fd, EVIOCGBIT(0, len(evbits)), evbits) | |
| return test_bit(event_type, evbits.raw) | |
| except (OSError, IOError): | |
| pass | |
| return False | |
| def has_abs_axis(fd, axis): | |
| """Check if device supports a specific absolute axis.""" | |
| # Allocate enough bytes to hold ABS_MAX bits (0x3f = 63) | |
| absbits = ctypes.create_string_buffer(8) | |
| try: | |
| fcntl.ioctl(fd, EVIOCGBIT(EV_ABS, len(absbits)), absbits) | |
| return test_bit(axis, absbits.raw) | |
| except (OSError, IOError): | |
| pass | |
| return False | |
| def check_device(device_path): | |
| """ | |
| Check if a device has problematic axis values. | |
| Returns a tuple (device_name, problematic_axes) where problematic_axes | |
| is a list of axis names that have min == max. | |
| """ | |
| try: | |
| fd = os.open(device_path, os.O_RDONLY | os.O_NONBLOCK) | |
| except (OSError, IOError): | |
| return None, [] | |
| try: | |
| # Get device name | |
| device_name = get_device_name(fd) | |
| if not device_name: | |
| return None, [] | |
| # Check if device supports absolute axes | |
| if not has_event_type(fd, EV_ABS): | |
| return None, [] | |
| # Check for problematic axes | |
| axes_to_check = { | |
| ABS_PRESSURE: 'ABS_PRESSURE', | |
| ABS_MT_TOUCH_MAJOR: 'ABS_MT_TOUCH_MAJOR', | |
| ABS_MT_TOUCH_MINOR: 'ABS_MT_TOUCH_MINOR', | |
| ABS_MT_WIDTH_MAJOR: 'ABS_MT_WIDTH_MAJOR', | |
| ABS_MT_WIDTH_MINOR: 'ABS_MT_WIDTH_MINOR', | |
| ABS_MT_PRESSURE: 'ABS_MT_PRESSURE', | |
| } | |
| problematic = [] | |
| for axis_code, axis_name in axes_to_check.items(): | |
| # First check if the device actually supports this axis | |
| if not has_abs_axis(fd, axis_code): | |
| continue | |
| absinfo = get_abs_info(fd, axis_code) | |
| if absinfo and absinfo.minimum == absinfo.maximum: | |
| problematic.append(axis_name) | |
| return device_name, problematic | |
| finally: | |
| os.close(fd) | |
| def is_mounted_ro(path): | |
| """Check if a path is on a read-only mounted filesystem.""" | |
| try: | |
| # Find the mount point for the path | |
| abs_path = os.path.abspath(path) | |
| # Read /proc/mounts to find the mount point | |
| with open('/proc/mounts', 'r') as f: | |
| mounts = f.readlines() | |
| # Find the longest matching mount point | |
| best_match = None | |
| best_match_len = 0 | |
| for line in mounts: | |
| parts = line.split() | |
| if len(parts) < 4: | |
| continue | |
| mount_point = parts[1] | |
| mount_options = parts[3].split(',') | |
| # Check if our path is under this mount point | |
| if abs_path.startswith(mount_point): | |
| if len(mount_point) > best_match_len: | |
| best_match = mount_point | |
| best_match_len = len(mount_point) | |
| is_ro = 'ro' in mount_options | |
| return is_ro if best_match else False | |
| except (OSError, IOError): | |
| return False | |
| def remount(path, mode): | |
| """ | |
| Remount a filesystem with the specified mode ('ro' or 'rw'). | |
| Args: | |
| path: Path on the filesystem to remount | |
| mode: Either 'ro' (read-only) or 'rw' (read-write) | |
| Returns: | |
| True if remount was successful, False otherwise | |
| """ | |
| try: | |
| # Find the actual mount point | |
| abs_path = os.path.abspath(path) | |
| with open('/proc/mounts', 'r') as f: | |
| mounts = f.readlines() | |
| mount_point = None | |
| best_match_len = 0 | |
| for line in mounts: | |
| parts = line.split() | |
| if len(parts) < 2: | |
| continue | |
| mp = parts[1] | |
| if abs_path.startswith(mp) and len(mp) > best_match_len: | |
| mount_point = mp | |
| best_match_len = len(mp) | |
| if mount_point: | |
| subprocess.run(['mount', '-o', f'remount,{mode}', mount_point], | |
| check=True, capture_output=True) | |
| return True | |
| except (OSError, IOError, subprocess.CalledProcessError) as e: | |
| print(f"WARNING: Could not remount {path} as {mode}: {e}", file=sys.stderr) | |
| return False | |
| def generate_quirks_config(devices): | |
| """Generate libinput quirks configuration for problematic devices.""" | |
| if not devices: | |
| return None | |
| config_lines = [ | |
| "# Auto-generated libinput quirks configuration", | |
| "# Generated by lxc-android-config", | |
| "#", | |
| "# This file works around kernel driver bugs where certain absolute", | |
| "# axes have min == max values, causing libinput to reject the device.", | |
| "", | |
| ] | |
| for device_name, axes in devices.items(): | |
| if not axes: | |
| continue | |
| # Create a quirk section for this device | |
| config_lines.append(f"[{device_name}]") | |
| config_lines.append(f"MatchName={device_name}") | |
| # Disable the problematic event codes | |
| disabled_codes = ";".join(axes) | |
| config_lines.append(f"AttrEventCodeDisable={disabled_codes};") | |
| config_lines.append("") | |
| return "\n".join(config_lines) | |
| def main(): | |
| """Main function.""" | |
| # Check for verbose mode | |
| verbose = '-v' in sys.argv or '--verbose' in sys.argv | |
| # Scan all event devices | |
| problematic_devices = {} | |
| event_devices = glob.glob('/dev/input/event*') | |
| if not event_devices: | |
| print("INFO: No input devices found", file=sys.stderr) | |
| return 0 | |
| for device_path in sorted(event_devices): | |
| device_name, problematic_axes = check_device(device_path) | |
| if device_name: | |
| if problematic_axes: | |
| print(f"Found problematic device: {device_name} ({device_path})") | |
| print(f" Problematic axes: {', '.join(problematic_axes)}") | |
| problematic_devices[device_name] = problematic_axes | |
| elif verbose: | |
| print(f"Checked device: {device_name} ({device_path}) - OK") | |
| # Generate quirks configuration | |
| if problematic_devices: | |
| config_content = generate_quirks_config(problematic_devices) | |
| # Ensure directory exists | |
| quirks_dir = '/etc/libinput' | |
| # Check if we need to remount /etc as read-write | |
| was_ro = is_mounted_ro(quirks_dir) | |
| if was_ro: | |
| remount(quirks_dir, 'rw') | |
| try: | |
| os.makedirs(quirks_dir, exist_ok=True) | |
| quirks_file = os.path.join(quirks_dir, 'local-overrides.quirks') | |
| # Write configuration | |
| with open(quirks_file, 'w') as f: | |
| f.write(config_content) | |
| print(f"Generated quirks configuration: {quirks_file}") | |
| return_code = 0 | |
| except (OSError, IOError) as e: | |
| print(f"ERROR: Could not write quirks file: {e}", file=sys.stderr) | |
| return_code = 1 | |
| finally: | |
| # Remount as read-only if it was originally read-only | |
| if was_ro: | |
| remount(quirks_dir, 'ro') | |
| return return_code | |
| else: | |
| print("No problematic devices found, no quirks needed.") | |
| return 0 | |
| if __name__ == '__main__': | |
| sys.exit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment