diff --git a/.gitignore b/.gitignore index 0728338..29db83b 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ target # Added by cargo /target +./lapctl.py diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..aba62a9 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,358 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "env_filter" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jiff" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "lapctl" +version = "0.1.0" +dependencies = [ + "clap", + "env_logger", + "log", + "regex", + "serde", + "serde_json", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 2cbc66d..ad2e133 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,3 +4,9 @@ version = "0.1.0" edition = "2024" [dependencies] +clap = { version = "4.5", features = ["derive"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +log = "0.4" +env_logger = "0.11" +regex = "1.10" diff --git a/lapctl.py b/lapctl.py new file mode 100644 index 0000000..4af4949 --- /dev/null +++ b/lapctl.py @@ -0,0 +1,815 @@ +#!/usr/bin/env python3 +import argparse +import logging +import os +import re +import shutil +import subprocess +import sys +from contextlib import contextmanager + +# begin constants definition + +VERSION = "3.5.2" + +# Note: Do NOT remove this in cleanup! +CACHE_FILE_PATH = "/var/cache/lapctl/cache.json" + +BLACKLIST_PATH = "/etc/modprobe.d/blacklist-nvidia.conf" + +BLACKLIST_CONTENT = """# Automatically generated by lapctl + +blacklist nouveau +blacklist nova_core +blacklist nova_drm +blacklist nvidia +blacklist nvidia_drm +blacklist nvidia_uvm +blacklist nvidia_modeset +blacklist nvidia_current +blacklist nvidia_current_drm +blacklist nvidia_current_uvm +blacklist nvidia_current_modeset +blacklist i2c_nvidia_gpu +alias nouveau off +alias nova_core off +alias nova_drm off +alias nvidia off +alias nvidia_drm off +alias nvidia_uvm off +alias nvidia_modeset off +alias nvidia_current off +alias nvidia_current_drm off +alias nvidia_current_uvm off +alias nvidia_current_modeset off +alias i2c_nvidia_gpu off +""" + +UDEV_INTEGRATED_PATH = "/etc/udev/rules.d/50-remove-nvidia.rules" + +UDEV_INTEGRATED = """# Automatically generated by lapctl + +# Remove NVIDIA USB xHCI Host Controller devices, if present +ACTION=="add", SUBSYSTEM=="pci", ATTR{vendor}=="0x10de", ATTR{class}=="0x0c0330", ATTR{power/control}="auto", ATTR{remove}="1" + +# Remove NVIDIA USB Type-C UCSI devices, if present +ACTION=="add", SUBSYSTEM=="pci", ATTR{vendor}=="0x10de", ATTR{class}=="0x0c8000", ATTR{power/control}="auto", ATTR{remove}="1" + +# Remove NVIDIA Audio devices, if present +ACTION=="add", SUBSYSTEM=="pci", ATTR{vendor}=="0x10de", ATTR{class}=="0x040300", ATTR{power/control}="auto", ATTR{remove}="1" + +# Remove NVIDIA VGA/3D controller devices +ACTION=="add", SUBSYSTEM=="pci", ATTR{vendor}=="0x10de", ATTR{class}=="0x03[0-9]*", ATTR{power/control}="auto", ATTR{remove}="1" +""" + +UDEV_PM_PATH = "/etc/udev/rules.d/80-nvidia-pm.rules" + +UDEV_PM_CONTENT = """# Automatically generated by lapctl + +# Remove NVIDIA USB xHCI Host Controller devices, if present +ACTION=="add", SUBSYSTEM=="pci", ATTR{vendor}=="0x10de", ATTR{class}=="0x0c0330", ATTR{remove}="1" + +# Remove NVIDIA USB Type-C UCSI devices, if present +ACTION=="add", SUBSYSTEM=="pci", ATTR{vendor}=="0x10de", ATTR{class}=="0x0c8000", ATTR{remove}="1" + +# Remove NVIDIA Audio devices, if present +ACTION=="add", SUBSYSTEM=="pci", ATTR{vendor}=="0x10de", ATTR{class}=="0x040300", ATTR{remove}="1" + +# Enable runtime PM for NVIDIA VGA/3D controller devices on driver bind +ACTION=="bind", SUBSYSTEM=="pci", ATTR{vendor}=="0x10de", ATTR{class}=="0x030000", TEST=="power/control", ATTR{power/control}="auto" +ACTION=="bind", SUBSYSTEM=="pci", ATTR{vendor}=="0x10de", ATTR{class}=="0x030200", TEST=="power/control", ATTR{power/control}="auto" + +# Disable runtime PM for NVIDIA VGA/3D controller devices on driver unbind +ACTION=="unbind", SUBSYSTEM=="pci", ATTR{vendor}=="0x10de", ATTR{class}=="0x030000", TEST=="power/control", ATTR{power/control}="on" +ACTION=="unbind", SUBSYSTEM=="pci", ATTR{vendor}=="0x10de", ATTR{class}=="0x030200", TEST=="power/control", ATTR{power/control}="on" +""" + +XORG_PATH = "/etc/X11/xorg.conf" + +XORG_INTEL = """# Automatically generated by lapctl + +Section "ServerLayout" + Identifier "layout" + Screen 0 "nvidia" + Inactive "intel" +EndSection + +Section "Device" + Identifier "nvidia" + Driver "nvidia" + BusID "{}" +EndSection + +Section "Screen" + Identifier "nvidia" + Device "nvidia" + Option "AllowEmptyInitialConfiguration" +EndSection + +Section "Device" + Identifier "intel" + Driver "modesetting" +EndSection + +Section "Screen" + Identifier "intel" + Device "intel" +EndSection +""" + +XORG_AMD = """# Automatically generated by lapctl + +Section "ServerLayout" + Identifier "layout" + Screen 0 "nvidia" + Inactive "amdgpu" +EndSection + +Section "Device" + Identifier "nvidia" + Driver "nvidia" + BusID "{}" +EndSection + +Section "Screen" + Identifier "nvidia" + Device "nvidia" + Option "AllowEmptyInitialConfiguration" +EndSection + +Section "Device" + Identifier "amdgpu" + Driver "amdgpu" +EndSection + +Section "Screen" + Identifier "amd" + Device "amdgpu" +EndSection +""" + +EXTRA_XORG_PATH = "/etc/X11/xorg.conf.d/10-nvidia.conf" + +EXTRA_XORG_CONTENT = """# Automatically generated by lapctl + +Section "OutputClass" + Identifier "nvidia" + MatchDriver "nvidia-drm" + Driver "nvidia" +""" + +FORCE_COMP = ' Option "ForceCompositionPipeline" "true"\n' + +COOLBITS = ' Option "Coolbits" "{}"\n' + +MODESET_PATH = "/etc/modprobe.d/nvidia.conf" + +MODESET_CONTENT = """# Automatically generated by lapctl + +options nvidia-drm modeset=1 +options nvidia NVreg_UsePageAttributeTable=1 NVreg_InitializeSystemMemoryAllocations=0 +""" + +MODESET_CURRENT_CONTENT = """# Automatically generated by lapctl + +options nvidia-current-drm modeset=1 +options nvidia-current NVreg_UsePageAttributeTable=1 NVreg_InitializeSystemMemoryAllocations=0 +""" + +MODESET_RTD3 = """# Automatically generated by lapctl + +options nvidia-drm modeset=1 +options nvidia "NVreg_DynamicPowerManagement=0x0{}" +options nvidia NVreg_UsePageAttributeTable=1 NVreg_InitializeSystemMemoryAllocations=0 +""" + +MODESET_CURRENT_RTD3 = """# Automatically generated by lapctl + +options nvidia-current-drm modeset=1 +options nvidia-current "NVreg_DynamicPowerManagement=0x0{}" +options nvidia-current NVreg_UsePageAttributeTable=1 NVreg_InitializeSystemMemoryAllocations=0 +""" + +SDDM_XSETUP_PATH = "/usr/share/sddm/scripts/Xsetup" + +SDDM_XSETUP_CONTENT = """#!/bin/sh +# Xsetup - run as root before the login dialog appears + +""" + +LIGHTDM_SCRIPT_PATH = "/etc/lightdm/nvidia.sh" + +LIGHTDM_CONFIG_PATH = "/etc/lightdm/lightdm.conf.d/20-nvidia.conf" + +LIGHTDM_CONFIG_CONTENT = """# Automatically generated by lapctl + +[Seat:*] +display-setup-script=/etc/lightdm/nvidia.sh +""" + +NVIDIA_XRANDR_SCRIPT = """#!/bin/sh +# Automatically generated by lapctl + +current="" + +xrandr --setprovideroutputsource "{}" NVIDIA-0 +xrandr --auto + +for next in $(xrandr --listmonitors | grep -E " *[0-9]+:.*" | cut -d" " -f6); do + [ -z "$current" ] && current=$next && continue + xrandr --output "$current" --auto --output "$next" --auto --right-of "$current" + current=$next +done +""" + +SUPPORTED_MODES = ["integrated", "hybrid", "nvidia"] +SUPPORTED_DISPLAY_MANAGERS = ["gdm", "gdm3", "sddm", "lightdm"] +RTD3_MODES = [0, 1, 2, 3] + +# end constants definition + + +def graphics_mode_switcher( + graphics_mode, + user_display_manager, + enable_force_comp, + coolbits_value, + rtd3_value, + use_nvidia_current, +): + print(f"Switching to {graphics_mode} mode") + + if graphics_mode == "integrated": + if logging.getLogger().level == logging.DEBUG: + service = subprocess.run( + ["systemctl", "disable", "nvidia-persistenced.service"] + ) + else: + service = subprocess.run( + ["systemctl", "disable", "nvidia-persistenced.service"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + if service.returncode == 0: + print("Successfully disabled nvidia-persistenced.service") + else: + logging.error("An error ocurred while disabling service") + + cleanup() + + # blacklist all nouveau, nova and Nvidia modules + create_file(BLACKLIST_PATH, BLACKLIST_CONTENT) + + # power off the Nvidia GPU with udev rules + create_file(UDEV_INTEGRATED_PATH, UDEV_INTEGRATED) + + rebuild_initramfs() + elif graphics_mode == "hybrid": + print( + f"Enable PCI-Express Runtime D3 (RTD3) Power Management: {rtd3_value or False}" + ) + cleanup() + + if logging.getLogger().level == logging.DEBUG: + service = subprocess.run( + ["systemctl", "enable", "nvidia-persistenced.service"] + ) + else: + service = subprocess.run( + ["systemctl", "enable", "nvidia-persistenced.service"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + if service.returncode == 0: + print("Successfully enabled nvidia-persistenced.service") + else: + logging.error("An error ocurred while enabling service") + + if rtd3_value == None: + if use_nvidia_current: + create_file(MODESET_PATH, MODESET_CURRENT_CONTENT) + else: + create_file(MODESET_PATH, MODESET_CONTENT) + else: + # setup rtd3 + if use_nvidia_current: + create_file(MODESET_PATH, MODESET_CURRENT_RTD3.format(rtd3_value)) + else: + create_file(MODESET_PATH, MODESET_RTD3.format(rtd3_value)) + create_file(UDEV_PM_PATH, UDEV_PM_CONTENT) + + rebuild_initramfs() + elif graphics_mode == "nvidia": + print(f"Enable ForceCompositionPipeline: {enable_force_comp}") + print(f"Enable Coolbits: {coolbits_value or False}") + + if logging.getLogger().level == logging.DEBUG: + service = subprocess.run( + ["systemctl", "enable", "nvidia-persistenced.service"] + ) + else: + service = subprocess.run( + ["systemctl", "enable", "nvidia-persistenced.service"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + if service.returncode == 0: + print("Successfully enabled nvidia-persistenced.service") + else: + logging.error("An error ocurred while enabling service") + + cleanup() + # get the Nvidia dGPU PCI bus + nvidia_gpu_pci_bus = get_nvidia_gpu_pci_bus() + + # get iGPU vendor + igpu_vendor = get_igpu_vendor() + + # create the X.org config + if igpu_vendor == "intel": + create_file(XORG_PATH, XORG_INTEL.format(nvidia_gpu_pci_bus)) + elif igpu_vendor == "amd": + create_file(XORG_PATH, XORG_AMD.format(nvidia_gpu_pci_bus)) + + # enable modeset for Nvidia driver + if use_nvidia_current: + create_file(MODESET_PATH, MODESET_CURRENT_CONTENT) + else: + create_file(MODESET_PATH, MODESET_CONTENT) + + # extra Xorg config + if enable_force_comp and coolbits_value != None: + create_file( + EXTRA_XORG_PATH, + EXTRA_XORG_CONTENT + + FORCE_COMP + + COOLBITS.format(coolbits_value) + + "EndSection\n", + ) + elif enable_force_comp: + create_file( + EXTRA_XORG_PATH, EXTRA_XORG_CONTENT + FORCE_COMP + "EndSection\n" + ) + elif coolbits_value != None: + create_file( + EXTRA_XORG_PATH, + EXTRA_XORG_CONTENT + COOLBITS.format(coolbits_value) + "EndSection\n", + ) + + # try to detect the display manager if not provided + if user_display_manager == None: + display_manager = get_display_manager() + else: + display_manager = user_display_manager + + # only sddm and lightdm require further config + if display_manager == "sddm": + # backup Xsetup + if os.path.exists(SDDM_XSETUP_PATH): + logging.info("Creating Xsetup backup") + with open(SDDM_XSETUP_PATH, mode="r", encoding="utf-8") as f: + create_file(SDDM_XSETUP_PATH + ".bak", f.read()) + create_file(SDDM_XSETUP_PATH, generate_xrandr_script(igpu_vendor), True) + elif display_manager == "lightdm": + create_file(LIGHTDM_SCRIPT_PATH, generate_xrandr_script(igpu_vendor), True) + create_file(LIGHTDM_CONFIG_PATH, LIGHTDM_CONFIG_CONTENT) + + rebuild_initramfs() + print("Operation completed successfully") + print("Please reboot your computer for changes to take effect!") + + +def cleanup(): + # define list of files to remove + to_remove = [ + BLACKLIST_PATH, + UDEV_INTEGRATED_PATH, + UDEV_PM_PATH, + XORG_PATH, + EXTRA_XORG_PATH, + MODESET_PATH, + LIGHTDM_SCRIPT_PATH, + LIGHTDM_CONFIG_PATH, + # legacy files + "/etc/X11/xorg.conf.d/90-nvidia.conf", + "/lib/udev/rules.d/50-remove-nvidia.rules", + "/lib/udev/rules.d/80-nvidia-pm.rules", + ] + + # remove each file in the list + for file_path in to_remove: + try: + if os.path.exists(file_path): + os.remove(file_path) + logging.info(f"Removed file {file_path}") + except OSError as e: + # only warn if file exists (code 2) + if e.errno != 2: + logging.error(f"Failed to remove file '{file_path}': {e}") + + # restore Xsetup backup if found + backup_path = SDDM_XSETUP_PATH + ".bak" + if os.path.exists(backup_path): + logging.info("Restoring Xsetup backup") + with open(backup_path, mode="r", encoding="utf-8") as f: + create_file(SDDM_XSETUP_PATH, f.read()) + # remove backup + os.remove(backup_path) + logging.info(f"Removed file {backup_path}") + + +def get_nvidia_gpu_pci_bus(): + lspci_output = subprocess.check_output(["lspci"]).decode("utf-8") + for line in lspci_output.splitlines(): + if "NVIDIA" in line and ( + "VGA compatible controller" in line or "3D controller" in line + ): + # remove leading zeros + pci_bus_id = line.split()[0].replace("0000:", "") + logging.info(f"Found Nvidia GPU at {pci_bus_id}") + break + else: + logging.error("Could not find Nvidia GPU") + print("Try switching to hybrid mode first!") + sys.exit(1) + + # need to return the BusID in 'PCI:bus:device:function' format + # also perform hexadecimal to decimal conversion + bus, device_function = pci_bus_id.split(":") + device, function = device_function.split(".") + return f"PCI:{int(bus, 16)}:{int(device, 16)}:{int(function, 16)}" + + +def get_igpu_vendor(): + lspci_output = subprocess.check_output(["lspci"]).decode("utf-8") + for line in lspci_output.splitlines(): + if "VGA compatible controller" in line or "Display controller" in line: + if "Intel" in line: + logging.info("Found Intel iGPU") + return "intel" + elif "ATI" in line or "AMD" in line or "AMD/ATI" in line: + logging.info("Found AMD iGPU") + return "amd" + logging.warning("Could not find Intel or AMD iGPU") + return None + + +def get_display_manager(): + try: + with open( + "/etc/systemd/system/display-manager.service", "r", encoding="utf-8" + ) as f: + content = f.read() + match = re.search(r"ExecStart=(.+)\n", content) + if match: + # only return the final component of the path + display_manager = os.path.basename(match.group(1)) + logging.info(f"Found {display_manager} Display Manager") + return display_manager + except FileNotFoundError: + logging.warning("Display Manager detection is not available") + + +def generate_xrandr_script(igpu_vendor): + if igpu_vendor == "intel": + return NVIDIA_XRANDR_SCRIPT.format("modesetting") + elif igpu_vendor == "amd": + amd_igpu_name = get_amd_igpu_name() + if amd_igpu_name != None: + return NVIDIA_XRANDR_SCRIPT.format(amd_igpu_name) + else: + return NVIDIA_XRANDR_SCRIPT.format("modesetting") + else: + return NVIDIA_XRANDR_SCRIPT.format("modesetting") + + +def get_amd_igpu_name(): + if not os.path.exists("/usr/bin/xrandr"): + logging.warning( + "The 'xrandr' command is not available. Make sure the package is installed!" + ) + return None + + try: + xrandr_output = subprocess.check_output(["xrandr", "--listproviders"]).decode( + "utf-8" + ) + except subprocess.CalledProcessError: + logging.warning("Failed to run the 'xrandr' command.") + + pattern = re.compile(r"(name:).*(ATI*|AMD*|AMD\/ATI)*") + + if pattern.findall(xrandr_output): + return re.search(pattern, xrandr_output).group(0)[5:] + else: + logging.warning("Could not find AMD iGPU in 'xrandr' output.") + return None + + +def rebuild_initramfs(): + # OSTree systems first + if any(os.path.exists(dir) for dir in ["/ostree", "/sysroot/ostree"]): + print("Rebuilding the initramfs with rpm-ostree...") + command = ["rpm-ostree", "initramfs", "--enable", "--arg=--force"] + + # Debian and Ubuntu derivatives + elif os.path.exists("/etc/debian_version"): + command = ["update-initramfs", "-u", "-k", "all"] + # RHEL and SUSE derivatives + elif os.path.exists("/etc/redhat-release") or os.path.exists("/usr/bin/zypper"): + command = ["dracut", "--force", "--regenerate-all"] + # EndeavourOS with dracut + elif os.path.exists("/usr/lib/endeavouros-release") and os.path.exists( + "/usr/bin/dracut" + ): + command = ["dracut-rebuild"] + # ALT Linux + elif os.path.exists("/etc/altlinux-release"): + command = ["make-initrd"] + # Arch Linux + elif os.path.exists("/etc/arch-release"): + command = ["mkinitcpio", "-P"] + else: + command = [] + + if shutil.which("systemd-inhibit"): + command = [ + "systemd-inhibit", + "--who=lapctl", + "--why", + "Rebuilding initramfs", + "--", + *command, + ] + + if len(command) != 0: + print("Rebuilding the initramfs...") + if logging.getLogger().level == logging.DEBUG: + p = subprocess.run(command) + else: + p = subprocess.run( + command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) + if p.returncode == 0: + print("Successfully rebuilt the initramfs!") + else: + logging.error("An error ocurred while rebuilding the initramfs") + + +def create_file(path, content, executable=False): + try: + # create the parent folders if needed + if not os.path.exists(os.path.dirname(path)): + os.makedirs(os.path.dirname(path)) + with open(path, mode="w", encoding="utf-8") as f: + f.write(content) + logging.info(f"Created file {path}") + if logging.getLogger().level == logging.DEBUG: + print(content) + + # add execution privilege + if executable: + subprocess.run(["chmod", "+x", path], stdout=subprocess.DEVNULL) + logging.info(f"Added execution privilege to file {path}") + except OSError as e: + logging.error(f"Failed to create file '{path}': {e}") + + +def assert_root(): + if os.geteuid() != 0: + logging.error("This operation requires root privileges") + sys.exit(1) + + +def main(): + # define CLI arguments + parser = argparse.ArgumentParser() + parser.add_argument( + "-v", + "--version", + action="version", + version=VERSION, + help="Output the current version", + ) + parser.add_argument( + "-q", "--query", action="store_true", help="Query the current graphics mode" + ) + parser.add_argument( + "-s", + "--switch", + type=str, + metavar="MODE", + action="store", + choices=SUPPORTED_MODES, + help="Switch the graphics mode. Available choices: %(choices)s", + ) + parser.add_argument( + "--dm", + type=str, + metavar="DISPLAY_MANAGER", + action="store", + choices=SUPPORTED_DISPLAY_MANAGERS, + help="Manually specify your Display Manager for Nvidia mode. Available choices: %(choices)s", + ) + parser.add_argument( + "--force-comp", + action="store_true", + help="Enable ForceCompositionPipeline on Nvidia mode", + ) + parser.add_argument( + "--coolbits", + type=int, + nargs="?", + metavar="VALUE", + action="store", + const=28, + help="Enable Coolbits on Nvidia mode. Default if specified: %(const)s", + ) + parser.add_argument( + "--rtd3", + type=int, + nargs="?", + metavar="VALUE", + action="store", + choices=RTD3_MODES, + const=2, + help="Setup PCI-Express Runtime D3 (RTD3) Power Management on Hybrid mode. Available choices: %(choices)s. Default if specified: %(const)s", + ) + parser.add_argument( + "--use-nvidia-current", + action="store_true", + help="Use nvidia-current instead of nvidia for kernel modules", + ) + parser.add_argument( + "--reset-sddm", action="store_true", help="Restore default Xsetup file" + ) + parser.add_argument( + "--reset", action="store_true", help="Revert changes made by lapctl" + ) + parser.add_argument( + "--cache-create", + action="store_true", + help="Create cache used by lapctl; only works in hybrid mode", + ) + parser.add_argument( + "--cache-delete", + action="store_true", + help="Delete cache created by lapctl", + ) + parser.add_argument( + "--cache-query", action="store_true", help="Show cache created by lapctl" + ) + parser.add_argument( + "--verbose", default=False, action="store_true", help="Enable verbose mode" + ) + + # print help if no arg is provided + if len(sys.argv) == 1: + parser.print_help() + sys.exit(1) + + args = parser.parse_args() + + # log formatting + logging.basicConfig(format="%(levelname)s: %(message)s") + + # set debug level for verbose mode + if args.verbose: + logging.getLogger().setLevel(logging.DEBUG) + + if args.query: + mode = get_current_mode() + print(mode) + return + elif args.cache_create: + assert_root() + CachedConfig(args).create_cache_file() + return + elif args.cache_delete: + assert_root() + CachedConfig.delete_cache_file() + return + elif args.cache_query: + CachedConfig.show_cache_file() + return + + if args.switch or args.reset_sddm or args.reset: + with CachedConfig(args).adapter(): + if args.switch: + assert_root() + graphics_mode_switcher( + args.switch, + args.dm, + args.force_comp, + args.coolbits, + args.rtd3, + args.use_nvidia_current, + ) + elif args.reset_sddm: + assert_root() + create_file(SDDM_XSETUP_PATH, SDDM_XSETUP_CONTENT, True) + print("Operation completed successfully") + elif args.reset: + assert_root() + cleanup() + CachedConfig.delete_cache_file() + rebuild_initramfs() + print("Operation completed successfully") + + +class CachedConfig: + """Adapter for config from CACHE_FILE_PATH""" + + def __init__(self, app_args) -> None: + self.app_args = app_args + self.current_mode = get_current_mode() + + @contextmanager + def adapter(self): + global get_nvidia_gpu_pci_bus + use_cache = os.path.exists(CACHE_FILE_PATH) + + if self.is_hybrid(): # recreate cache file when in hybrid mode + self.create_cache_file() + + if use_cache: + self.read_cache_file() # might not be in hybrid mode + + # rebind function to use cached value instead of detection + get_nvidia_gpu_pci_bus = self.get_nvidia_gpu_pci_bus + + yield # back to main ... + + def create_cache_file(self): + if not self.is_hybrid(): + raise ValueError( + "--cache-create requires that the system be in the hybrid Optimus mode" + ) + + self.nvidia_gpu_pci_bus = get_nvidia_gpu_pci_bus() + self.obj = self.create_cache_obj(self.nvidia_gpu_pci_bus) + self.write_cache_file() + + def create_cache_obj(self, nvidia_gpu_pci_bus): + return {"nvidia_gpu_pci_bus": nvidia_gpu_pci_bus} + + def is_hybrid(self): + return "hybrid" == self.current_mode + + def get_nvidia_gpu_pci_bus(self): + return self.nvidia_gpu_pci_bus + + @staticmethod + def delete_cache_file(): + os.remove(CACHE_FILE_PATH) + os.removedirs(os.path.dirname(CACHE_FILE_PATH)) + logging.debug(f"Removed file {CACHE_FILE_PATH}") + + def read_cache_file(self): + from json import loads + + if os.path.exists(CACHE_FILE_PATH): + with open(CACHE_FILE_PATH, "r", encoding="utf-8") as f: + content = f.read() + self.obj = loads(content) + self.nvidia_gpu_pci_bus = self.obj["nvidia_gpu_pci_bus"] + elif self.is_hybrid(): + self.nvidia_gpu_pci_bus = get_nvidia_gpu_pci_bus() + else: + raise ValueError( + "No cache present. Operation requires that the system be in the hybrid Optimus mode" + ) + + @staticmethod + def show_cache_file(): + content = f"ERROR: Could not read {CACHE_FILE_PATH}" + if os.path.exists(CACHE_FILE_PATH): + with open(CACHE_FILE_PATH, "r", encoding="utf-8") as f: + content = f.read() + print(content) + + def write_cache_file(self): + from json import dump + + os.makedirs(os.path.dirname(CACHE_FILE_PATH), exist_ok=True) + + with open(CACHE_FILE_PATH, "w", encoding="utf-8") as f: + dump(self.obj, fp=f, indent=4, sort_keys=False) + + logging.debug(f"Created file {CACHE_FILE_PATH}") + + +def get_current_mode(): + mode = "hybrid" + if os.path.exists(BLACKLIST_PATH) and ( + os.path.exists(UDEV_INTEGRATED_PATH) + or os.path.exists("/lib/udev/rules.d/50-remove-nvidia.rules") + ): + mode = "integrated" + elif os.path.exists(XORG_PATH) and os.path.exists(MODESET_PATH): + mode = "nvidia" + return mode + + +if __name__ == "__main__": + main()