diff --git a/nvidia-optimus/Assets/nvidia-eye.svg b/nvidia-optimus/Assets/nvidia-eye.svg new file mode 100644 index 000000000..35dcbfd28 --- /dev/null +++ b/nvidia-optimus/Assets/nvidia-eye.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/nvidia-optimus/BarWidget.qml b/nvidia-optimus/BarWidget.qml new file mode 100644 index 000000000..2a41f8e46 --- /dev/null +++ b/nvidia-optimus/BarWidget.qml @@ -0,0 +1,86 @@ +import QtQuick +import QtQuick.Effects +import Quickshell +import qs.Commons +import qs.Services.UI +import qs.Widgets + +// Bar button: the NVIDIA "eye" mark, tinted to the active Noctalia theme — the accent +// colour (Color.mPrimary) when the dGPU is awake / driving a display, and a neutral +// colour (Color.mOnSurface) when it is asleep. Hidden when no NVIDIA dGPU is present. +NIconButton { + id: root + + property var pluginApi: null + + property ShellScreen screen + property string widgetId: "" + property string section: "" + property int sectionWidgetIndex: -1 + property int sectionWidgetsCount: 0 + + readonly property var main: pluginApi?.mainInstance + readonly property string rt: main ? main.runtime : "unknown" + readonly property bool ext: main ? main.external : false + readonly property string md: main ? main.mode : "battery" + readonly property bool present: main ? main.present : true + readonly property bool lit: root.ext || root.rt === "active" + + visible: root.present + icon: "" // hidden: we draw the tinted NVIDIA mark below + + tooltipText: { + var api = root.pluginApi; + if (root.ext) + return api?.tr("bar.tooltip-external"); + var state = (root.rt === "active") ? api?.tr("common.state-active") : api?.tr("common.state-asleep"); + var mode = (root.md === "hdmi") ? api?.tr("common.mode-hdmi") : api?.tr("common.mode-battery"); + return api?.tr("bar.tooltip", { + "state": state, + "mode": mode + }); + } + tooltipDirection: BarService.getTooltipDirection(screen?.name) + baseSize: Style.getCapsuleHeightForScreen(screen?.name) + applyUiScale: false + customRadius: Style.radiusL + colorBg: Style.capsuleColor + border.color: Style.capsuleBorderColor + border.width: Style.capsuleBorderWidth + + Item { + id: mark + anchors.centerIn: parent + width: Math.round(root.buttonSize * 0.9) + height: width + + Image { + id: eyeImg + anchors.fill: parent + source: Qt.resolvedUrl("Assets/nvidia-eye.svg") + fillMode: Image.PreserveAspectFit + sourceSize.width: mark.width + sourceSize.height: mark.height + smooth: true + mipmap: true + visible: false // shown via the tinting effect below + } + + MultiEffect { + anchors.fill: eyeImg + source: eyeImg + colorization: 1.0 + colorizationColor: root.lit ? Color.mPrimary : Color.mOnSurface + Behavior on colorizationColor { + ColorAnimation { + duration: Style.animationFast + } + } + } + } + + onClicked: { + if (pluginApi) + pluginApi.openPanel(root.screen, this); + } +} diff --git a/nvidia-optimus/Main.qml b/nvidia-optimus/Main.qml new file mode 100644 index 000000000..74740b3e3 --- /dev/null +++ b/nvidia-optimus/Main.qml @@ -0,0 +1,109 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Commons + +// Headless logic + polling for the NVIDIA Optimus plugin. +// The background poll runs the bundled `scripts/gpu-stat` in SYSFS-ONLY mode, so it +// never wakes — or holds awake — a sleeping dGPU. Detailed nvidia-smi stats are +// polled by the Panel only while it is open. +Item { + id: root + + property var pluginApi: null + + // Bundled helper scripts, resolved relative to this plugin's directory and run via + // bash (so no execute bit is required after a plain `git clone`). + readonly property string pluginDir: Qt.resolvedUrl(".").toString().replace(/^file:\/\//, "") + readonly property string statCmd: root.pluginDir + "scripts/gpu-stat" + readonly property string toggleCmd: root.pluginDir + "scripts/gpu-toggle" + + property var stats: ({ + "runtime": "unknown", + "mode": "battery", + "pending": "battery", + "external": "none" + }) + + readonly property string runtime: (stats && stats.runtime) ? stats.runtime : "unknown" + readonly property string mode: (stats && stats.mode) ? stats.mode : "battery" // live + readonly property string pending: (stats && stats.pending) ? stats.pending : mode // next login + readonly property bool present: runtime !== "absent" + readonly property bool active: runtime === "active" + readonly property bool external: !!(stats && stats.external && stats.external !== "none") + readonly property bool needsRestart: pending !== mode + property bool busy: false + + function refresh() { + if (statProc.running) + return; + statProc.command = ["bash", root.statCmd]; + statProc.running = true; + } + + function setMode(m) { + busy = true; + ctlProc.command = ["bash", root.toggleCmd, m]; + ctlProc.running = true; + refresh(); + } + + function toggle() { + if (busy) + return; + setMode(pending === "hdmi" ? "battery" : "hdmi"); + } + + function apply() { + applyProc.running = true; // restarts the Hyprland session to pick up the env change + } + + // qs -c noctalia-shell ipc call plugin:nvidia-optimus toggle (open panel) + // qs -c noctalia-shell ipc call plugin:nvidia-optimus mode (flip Battery/HDMI) + IpcHandler { + target: "plugin:nvidia-optimus" + + function toggle(): void { + if (root.pluginApi) + root.pluginApi.withCurrentScreen(screen => root.pluginApi.togglePanel(screen)); + } + + function mode(): void { + root.toggle(); + } + } + + Process { + id: statProc + stdout: StdioCollector { + onStreamFinished: { + try { + root.stats = JSON.parse(this.text); + } catch (e) { + Logger.w("NvidiaOptimus", "Failed to parse gpu-stat output"); + } + } + } + } + + Process { + id: ctlProc + onExited: function (code) { + root.busy = false; + root.refresh(); + } + } + + Process { + id: applyProc + command: ["hyprctl", "dispatch", "exit"] + } + + Timer { + interval: 2000 + running: true + repeat: true + triggeredOnStart: true + onTriggered: root.refresh() + } +} diff --git a/nvidia-optimus/Panel.qml b/nvidia-optimus/Panel.qml new file mode 100644 index 000000000..d0ca4e926 --- /dev/null +++ b/nvidia-optimus/Panel.qml @@ -0,0 +1,302 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Effects +import Quickshell +import Quickshell.Io +import qs.Commons +import qs.Widgets + +// Popover: themed NVIDIA header with the Battery⇄HDMI toggle, live dGPU stats, the +// external-display readout, and the list of processes holding the GPU. Detailed +// nvidia-smi stats are polled ONLY while this panel is visible. +Item { + id: root + + property var pluginApi: null + + readonly property var geometryPlaceholder: panelContainer + property real contentPreferredWidth: 360 * Style.uiScaleRatio + property real contentPreferredHeight: (panelCol.implicitHeight + Style.marginL * 2) * Style.uiScaleRatio + readonly property bool allowAttach: true + + anchors.fill: parent + + readonly property var main: pluginApi?.mainInstance + readonly property var s: main ? main.stats : ({}) + readonly property string runtime: (s && s.runtime) ? s.runtime : "unknown" + readonly property bool active: runtime === "active" + readonly property string pending: main ? main.pending : "battery" + readonly property bool needsRestart: main ? main.needsRestart : false + readonly property bool ext: main ? main.external : false + readonly property bool lit: root.ext || root.active + + // Detailed nvidia-smi stats — polled ONLY while this panel is visible, so the + // background bar poll never wakes the dGPU. + property var full: ({}) + + Process { + id: fullProc + stdout: StdioCollector { + onStreamFinished: { + try { + root.full = JSON.parse(this.text); + } catch (e) {} + } + } + } + + Timer { + interval: 2000 + running: root.visible && !!root.main + repeat: true + triggeredOnStart: true + onTriggered: { + if (root.main && !fullProc.running) { + fullProc.command = ["bash", root.main.statCmd, "full"]; + fullProc.running = true; + } + } + } + + function fmt1(n) { + return (Math.round((Number(n) || 0) * 10) / 10).toFixed(1); + } + + function subtitle() { + var api = root.pluginApi; + if (root.needsRestart) { + var m = (root.pending === "hdmi") ? api?.tr("common.mode-hdmi") : api?.tr("common.mode-battery"); + return api?.tr("panel.subtitle-pending", { + "mode": m + }); + } + if (root.pending === "hdmi") + return root.ext ? api?.tr("panel.subtitle-driving") : api?.tr("panel.subtitle-hdmi"); + return root.active ? api?.tr("panel.subtitle-battery-awake") : api?.tr("panel.subtitle-battery-asleep"); + } + + function statRows() { + var d = root.full || {}; + var api = root.pluginApi; + return [ + { + "icon": "bolt", + "label": api?.tr("panel.stat-power"), + "value": fmt1(d.powerW) + " W" + }, { + "icon": "temperature", + "label": api?.tr("panel.stat-temp"), + "value": (d.tempC || 0) + "°C" + }, { + "icon": "activity", + "label": api?.tr("panel.stat-util"), + "value": (d.util || 0) + "%" + }, { + "icon": "memory", + "label": api?.tr("panel.stat-vram"), + "value": fmt1(d.vramUsed) + " / " + fmt1(d.vramTotal) + " GB" + } + ]; + } + + Item { + id: panelContainer + anchors.fill: parent + + ColumnLayout { + id: panelCol + anchors.fill: parent + anchors.margins: Style.marginL + spacing: Style.marginM + + // Header: themed NVIDIA mark + title + Battery/HDMI toggle + RowLayout { + Layout.fillWidth: true + spacing: Style.marginM + + Item { + id: headerMark + implicitWidth: Math.round(Style.fontSizeXXL * 1.7) + implicitHeight: Math.round(Style.fontSizeXXL * 1.2) + width: implicitWidth + height: implicitHeight + + Image { + id: headerEye + anchors.fill: parent + source: Qt.resolvedUrl("Assets/nvidia-eye.svg") + fillMode: Image.PreserveAspectFit + sourceSize.width: parent.width + sourceSize.height: parent.height + smooth: true + mipmap: true + visible: false + } + MultiEffect { + anchors.fill: headerEye + source: headerEye + colorization: 1.0 + colorizationColor: root.lit ? Color.mPrimary : Color.mOnSurface + Behavior on colorizationColor { + ColorAnimation { + duration: Style.animationFast + } + } + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + NText { + text: (root.s && root.s.gpuName) ? root.s.gpuName : root.pluginApi?.tr("panel.title-fallback") + font.weight: Style.fontWeightBold + pointSize: Style.fontSizeL + color: Color.mOnSurface + } + + NText { + text: root.subtitle() + pointSize: Style.fontSizeS + color: root.needsRestart ? Color.mError : (root.pending === "hdmi" ? Color.mPrimary : Color.mOnSurfaceVariant) + } + } + + NToggle { + checked: root.pending === "hdmi" + onToggled: { + if (root.main) + root.main.toggle(); + } + } + } + + NDivider { + Layout.fillWidth: true + } + + // Mode context: external display + what is holding the dGPU awake + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginS + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginM + NIcon { + icon: "device-tv" + color: root.ext ? Color.mPrimary : Color.mOnSurfaceVariant + pointSize: Style.fontSizeM + } + NText { + text: root.pluginApi?.tr("panel.external") + color: Color.mOnSurfaceVariant + pointSize: Style.fontSizeS + Layout.fillWidth: true + } + NText { + text: root.ext ? ((root.s.external || "?") + " · " + (root.s.externalName || "")) : root.pluginApi?.tr("common.none") + color: root.ext ? Color.mPrimary : Color.mOnSurface + pointSize: Style.fontSizeS + font.weight: Style.fontWeightBold + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginM + visible: root.active + NIcon { + icon: "stack" + color: Color.mOnSurfaceVariant + pointSize: Style.fontSizeM + } + NText { + text: root.pluginApi?.tr("panel.gpu-processes") + color: Color.mOnSurfaceVariant + pointSize: Style.fontSizeS + Layout.fillWidth: true + } + NText { + text: (root.full && root.full.heldBy && root.full.heldBy.length > 0) ? root.full.heldBy : root.pluginApi?.tr("panel.none-reported") + color: Color.mOnSurface + pointSize: Style.fontSizeS + font.weight: Style.fontWeightBold + elide: Text.ElideRight + Layout.maximumWidth: 180 * Style.uiScaleRatio + } + } + } + + NDivider { + Layout.fillWidth: true + } + + // Live dGPU stats (dimmed while the dGPU is asleep) + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginS + opacity: root.active ? 1.0 : 0.4 + + Repeater { + model: root.statRows() + + delegate: RowLayout { + Layout.fillWidth: true + spacing: Style.marginM + + NIcon { + icon: modelData.icon + color: Color.mOnSurfaceVariant + pointSize: Style.fontSizeM + } + NText { + text: modelData.label + color: Color.mOnSurfaceVariant + pointSize: Style.fontSizeS + Layout.fillWidth: true + } + NText { + text: modelData.value + color: Color.mOnSurface + pointSize: Style.fontSizeS + font.weight: Style.fontWeightBold + } + } + } + } + + // Apply banner — only when a mode change is pending + NDivider { + Layout.fillWidth: true + visible: root.needsRestart + } + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginM + visible: root.needsRestart + + NText { + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: root.pluginApi?.tr("panel.apply-note") + pointSize: Style.fontSizeXS + color: Color.mOnSurfaceVariant + } + + NButton { + text: root.pluginApi?.tr("panel.apply-button") + fontSize: Style.fontSizeS + backgroundColor: Color.mError + textColor: Color.mOnError + onClicked: { + if (root.main) + root.main.apply(); + } + } + } + } + } +} diff --git a/nvidia-optimus/README.md b/nvidia-optimus/README.md new file mode 100644 index 000000000..0c8c2a77a --- /dev/null +++ b/nvidia-optimus/README.md @@ -0,0 +1,102 @@ +# NVIDIA Optimus — Noctalia plugin + +A bar widget + panel for **[Noctalia](https://github.com/noctalia-dev/noctalia-shell)** (Quickshell) +that monitors and controls an **NVIDIA Optimus dGPU on Hyprland**. + +The NVIDIA "eye" in your bar is **tinted to your theme** — accent colour when the dGPU is +awake or driving a display, neutral when it's asleep — so at a glance you know whether the +discrete GPU is burning battery. Click it for a panel with a **Battery ⇄ HDMI-Ready toggle**, +live power/temp/VRAM, and a list of exactly what's holding the GPU awake (including +container/root processes that `lsof` can't see). + +> **Why this exists:** on Hyprland, an NVIDIA Optimus dGPU often refuses to enter RTD3 +> runtime-suspend even when nothing is using it, quietly draining battery — while the HDMI +> port is frequently wired to the dGPU, so you *can't* just disable it. This plugin gives you +> a one-click way to switch between "let the dGPU sleep" and "I need the external display," +> and shows you the truth about its power state. + +## What it does + +- **Bar icon**: theme-tinted NVIDIA mark. Accent = dGPU awake, neutral = asleep. Hidden if no + NVIDIA dGPU is present. Hover for state + mode; click to open the panel. +- **Battery mode**: drops the dGPU from Hyprland's `AQ_DRM_DEVICES` **and** forces the session + onto mesa-only EGL, so the compositor stops pinning the dGPU — it can finally RTD3-suspend. + (Excluding it from `AQ_DRM_DEVICES` alone is *not* enough; Hyprland + hyprpaper still open the + nvidia nodes via EGL.) No external output in this mode. +- **HDMI-Ready mode**: adds the dGPU back so external displays wired to it work. +- **Live panel**: power draw, temperature, utilisation, VRAM, external-display readout, and the + full list of processes on the GPU (compute *and* graphics, with PID). +- **Zero-overhead monitoring**: the 2-second background poll is **sysfs-only** — it never runs + `nvidia-smi` or `lspci`, so it can never wake or hold-awake a sleeping dGPU. `nvidia-smi` runs + only while the panel is open, and only when the GPU is already awake. + +## Requirements + +- **Hyprland** (uses `AQ_DRM_DEVICES`, an aquamarine/Hyprland env var) +- **Noctalia** ≥ 4.0 (Quickshell) +- An **NVIDIA Optimus laptop** with the proprietary driver and `nvidia-drm.modeset=1` +- Any iGPU (Intel or AMD) — the plugin auto-detects both GPUs from sysfs +- `bash`, `python3`, `lsof`, and (for the panel's live stats) `nvidia-smi` + +## Install + +```bash +git clone https://github.com/joniler/noctalia-nvidia-optimus \ + ~/.config/noctalia/plugins/nvidia-optimus +``` + +Then in Noctalia: **Settings → Plugins**, enable **NVIDIA Optimus**, and add its widget to your +bar (**Settings → Bar**). No execute bit needed — the helper scripts run via `bash`. + +Optional keybind (Hyprland), matching the panel toggle: + +``` +bind = $mod CTRL SHIFT, G, exec, qs -c noctalia-shell ipc call nvidiaOptimus toggle +``` + +## Usage + +- **Click the eye** → panel. The header toggle flips **Battery ⇄ HDMI-Ready**. +- Mode changes are written to `~/.config/hypr/env.conf` (backed up next to it) and **apply on the + next login** — `AQ_DRM_DEVICES` is only read at session start. The panel shows a red + **"Apply now (log out)"** button while a change is pending. +- IPC: `qs -c noctalia-shell ipc call nvidiaOptimus toggle` (open panel) / + `... nvidiaOptimus mode` (flip mode). + +If your Hyprland env file lives somewhere other than `~/.config/hypr/env.conf`, set +`HYPR_ENV_FILE` in that file's own environment before the plugin's scripts run (or edit the +`env_file` helper in `scripts/gpu-detect.sh`). + +## How the modes look in `env.conf` + +```ini +# Battery — dGPU sleeps, no HDMI +env = AQ_DRM_DEVICES,/dev/dri/cardN # iGPU only +env = __EGL_VENDOR_LIBRARY_FILENAMES,/usr/share/glvnd/egl_vendor.d/50_mesa.json + +# HDMI-Ready — external outputs work, dGPU stays awake +env = AQ_DRM_DEVICES,/dev/dri/cardN:/dev/dri/cardM # iGPU + dGPU +``` + +Card nodes are resolved from **PCI addresses** on every switch, so this survives the +`/dev/dri/cardN` renumbering that happens when GPU modes change. + +## Notes & caveats + +- **Hyprland-only.** The mode toggle is built on `AQ_DRM_DEVICES`; it won't do anything on other + compositors. The monitoring half (icon + power state) is harmless everywhere but only useful + with an NVIDIA dGPU. +- **A running CUDA/GL client keeps the dGPU awake** — that's correct behaviour. The panel's + "GPU processes" row tells you which one (e.g. a stray ComfyUI/Ollama container). Battery mode + can't sleep a GPU that something is actively using. +- Opening the panel wakes the dGPU (to read `nvidia-smi`); the **bar icon** is the honest at-rest + indicator, since it never touches the GPU. + +## Credits + +- NVIDIA "eye" glyph derived from the [Papirus icon theme](https://github.com/PapirusDevelopmentTeam/papirus-icon-theme) (GPLv3), recoloured to a single silhouette and tinted at runtime. +- Built for [Noctalia](https://github.com/noctalia-dev/noctalia-shell). + +## License + +MIT © Jon Iler ([@joniler](https://github.com/joniler)) diff --git a/nvidia-optimus/Settings.qml b/nvidia-optimus/Settings.qml new file mode 100644 index 000000000..d420041af --- /dev/null +++ b/nvidia-optimus/Settings.qml @@ -0,0 +1,43 @@ +import QtQuick +import QtQuick.Layouts +import qs.Commons +import qs.Widgets + +// Settings view for the NVIDIA Optimus plugin. +Item { + id: root + + property var pluginApi: null + + implicitHeight: col.implicitHeight + + ColumnLayout { + id: col + anchors.left: parent.left + anchors.right: parent.right + spacing: Style.marginM + + NText { + text: root.pluginApi?.tr("settings.title") + font.weight: Style.fontWeightBold + pointSize: Style.fontSizeL + color: Color.mOnSurface + } + + NText { + Layout.fillWidth: true + wrapMode: Text.WordWrap + pointSize: Style.fontSizeS + color: Color.mOnSurfaceVariant + text: root.pluginApi?.tr("settings.description") + } + + NText { + Layout.fillWidth: true + wrapMode: Text.WordWrap + pointSize: Style.fontSizeXS + color: Color.mOnSurfaceVariant + text: root.pluginApi?.tr("settings.requirements") + } + } +} diff --git a/nvidia-optimus/i18n/en.json b/nvidia-optimus/i18n/en.json new file mode 100644 index 000000000..08786d87e --- /dev/null +++ b/nvidia-optimus/i18n/en.json @@ -0,0 +1,35 @@ +{ + "common": { + "state-active": "active", + "state-asleep": "asleep", + "mode-hdmi": "HDMI-Ready", + "mode-battery": "Battery", + "none": "none" + }, + "bar": { + "tooltip-external": "dGPU — driving external display", + "tooltip": "dGPU — {state} ({mode} mode)" + }, + "panel": { + "title-fallback": "NVIDIA dGPU", + "subtitle-pending": "{mode} pending — restart to apply", + "subtitle-driving": "Driving external display", + "subtitle-hdmi": "HDMI-Ready — dGPU on demand", + "subtitle-battery-awake": "Battery — dGPU awake", + "subtitle-battery-asleep": "Battery — dGPU asleep", + "external": "External display", + "gpu-processes": "GPU processes", + "none-reported": "none reported", + "stat-power": "Power draw", + "stat-temp": "GPU temp", + "stat-util": "GPU util", + "stat-vram": "VRAM", + "apply-note": "AQ_DRM_DEVICES is read at session start.", + "apply-button": "Apply now (log out)" + }, + "settings": { + "title": "NVIDIA Optimus", + "description": "The bar toggle flips Hyprland's AQ_DRM_DEVICES between Battery (iGPU only — the NVIDIA dGPU runtime-suspends, no HDMI) and HDMI-Ready (iGPU + dGPU — external outputs work). It edits ~/.config/hypr/env.conf (backed up alongside) and applies on the next login. The bar icon is theme-tinted: accent when the dGPU is awake, neutral when asleep. Stats poll every 2s from a bundled sysfs-only script that never wakes the GPU; nvidia-smi runs only while this panel is open, and only when the GPU is already awake.", + "requirements": "Requires: Hyprland (aquamarine), an NVIDIA Optimus laptop with the proprietary driver + nvidia-drm.modeset=1. Override the env file with $HYPR_ENV_FILE if yours lives elsewhere." + } +} diff --git a/nvidia-optimus/manifest.json b/nvidia-optimus/manifest.json new file mode 100644 index 000000000..d23163c82 --- /dev/null +++ b/nvidia-optimus/manifest.json @@ -0,0 +1,26 @@ +{ + "id": "nvidia-optimus", + "name": "NVIDIA Optimus", + "version": "1.0.0", + "minNoctaliaVersion": "4.0.0", + "author": "joniler", + "license": "MIT", + "repository": "https://github.com/noctalia-dev/noctalia-plugins", + "description": "Monitor and control an NVIDIA Optimus dGPU on Hyprland — toggle Battery (dGPU sleeps) vs HDMI-Ready, see live power/VRAM, and list what is holding the GPU awake.", + "tags": ["Bar", "Panel", "System", "GPU", "Hyprland", "NVIDIA"], + "entryPoints": { + "main": "Main.qml", + "barWidget": "BarWidget.qml", + "panel": "Panel.qml", + "settings": "Settings.qml" + }, + "dependencies": { + "plugins": [] + }, + "metadata": { + "panel": { + "defaultPosition": "right" + }, + "defaultSettings": {} + } +} diff --git a/nvidia-optimus/preview.png b/nvidia-optimus/preview.png new file mode 100644 index 000000000..6d088b235 Binary files /dev/null and b/nvidia-optimus/preview.png differ diff --git a/nvidia-optimus/scripts/gpu-detect.sh b/nvidia-optimus/scripts/gpu-detect.sh new file mode 100644 index 000000000..e32e0712d --- /dev/null +++ b/nvidia-optimus/scripts/gpu-detect.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# gpu-detect.sh — shared helpers for the noctalia nvidia-optimus plugin. +# Sourced by gpu-stat and gpu-toggle. Detects the iGPU (Intel/AMD) and the NVIDIA +# dGPU from sysfs ONLY — never lspci — so detection never resumes a suspended GPU. + +# Populates IGPU_PCI and DGPU_PCI (e.g. 0000:00:02.0). Empty if not present. +detect_gpus() { + IGPU_PCI=""; DGPU_PCI="" + local d cls ven pci + for d in /sys/bus/pci/devices/*; do + cls=$(cat "$d/class" 2>/dev/null) || continue # cached attr, does not wake device + case "$cls" in + 0x0300*|0x0302*) ;; # VGA or 3D controller + *) continue ;; + esac + ven=$(cat "$d/vendor" 2>/dev/null) + pci=$(basename "$d") + case "$ven" in + 0x10de) [ -z "$DGPU_PCI" ] && DGPU_PCI="$pci" ;; # NVIDIA + 0x8086|0x1002) [ -z "$IGPU_PCI" ] && IGPU_PCI="$pci" ;; # Intel / AMD + esac + done +} + +# Resolve the /dev/dri/cardN node for a PCI address (cardN numbering is not stable). +card_for() { # $1=pci -> cardN + local c p + for c in /dev/dri/card[0-9]*; do + p=$(basename "$(readlink -f "/sys/class/drm/$(basename "$c")/device" 2>/dev/null)") + [ "$p" = "$1" ] && { basename "$c"; return; } + done +} + +# Marketing name of the dGPU WITHOUT waking it: reads the device ID from sysfs and +# resolves it against the pci.ids database (plain file reads). Falls back to lspci +# (a one-time config-space read) only if pci.ids is unavailable. +gpu_name() { # uses $DGPU_PCI + local vid did f raw="" + vid=$(cat "/sys/bus/pci/devices/$DGPU_PCI/vendor" 2>/dev/null); vid=${vid#0x} + did=$(cat "/sys/bus/pci/devices/$DGPU_PCI/device" 2>/dev/null); did=${did#0x} + for f in /usr/share/hwdata/pci.ids /usr/share/misc/pci.ids; do + [ -r "$f" ] || continue + raw=$(awk -v v="$vid" -v d="$did" ' + /^[0-9a-fA-F]{4} / { invend = ($1 == v) } + invend && index($0, "\t" d " ") == 1 { sub(/^\t[0-9a-fA-F]{4} /, ""); print; exit } + ' "$f") + [ -n "$raw" ] && break + done + [ -z "$raw" ] && raw=$(lspci -s "$DGPU_PCI" 2>/dev/null | sed -E 's/^.*: //') + # Prefer the bracketed marketing name, e.g. "GB205M [GeForce RTX 5070 Ti Mobile]". + case "$raw" in *\[*\]*) raw=$(printf '%s' "$raw" | sed -E 's/.*\[([^]]*)\].*/\1/') ;; esac + raw=$(printf '%s' "$raw" | sed -E 's/GeForce +//; s/ +\(rev.*//; s/ +$//') + [ -n "$raw" ] && printf '%s' "$raw" || printf 'NVIDIA dGPU' +} + +# Path of the mesa EGL vendor JSON (used to force mesa-only EGL in Battery mode). +mesa_egl_json() { + local f + for f in /usr/share/glvnd/egl_vendor.d/*mesa*.json \ + /usr/local/share/glvnd/egl_vendor.d/*mesa*.json; do + [ -e "$f" ] && { echo "$f"; return; } + done + echo "/usr/share/glvnd/egl_vendor.d/50_mesa.json" # sensible default +} + +# Hyprland env file that holds the AQ_DRM_DEVICES / EGL lines. +env_file() { echo "${HYPR_ENV_FILE:-$HOME/.config/hypr/env.conf}"; } diff --git a/nvidia-optimus/scripts/gpu-stat b/nvidia-optimus/scripts/gpu-stat new file mode 100755 index 000000000..090b4bb1d --- /dev/null +++ b/nvidia-optimus/scripts/gpu-stat @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# gpu-stat — emit JSON describing the NVIDIA dGPU power state + current graphics mode. +# Consumed by the noctalia nvidia-optimus plugin (BarWidget + Panel). +# +# Default (background) mode is SYSFS-ONLY and never runs nvidia-smi or lspci, so it +# can be polled every couple of seconds without ever waking — or holding awake — a +# sleeping dGPU. Pass "full" (only while the panel is open) to add nvidia-smi +# power/temp/util/VRAM and the list of processes holding a context (this DOES wake +# the GPU, so the plugin only requests it when the GPU is already awake). +set -uo pipefail + +SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=gpu-detect.sh +. "$SELF_DIR/gpu-detect.sh" + +MODE_ARG="${1:-}" +detect_gpus +ENVFILE="$(env_file)" + +# No NVIDIA dGPU present — emit a sentinel so the UI can hide/disable itself. +if [ -z "${DGPU_PCI:-}" ]; then + echo '{"runtime":"absent","mode":"battery","pending":"battery","external":"none","externalName":"","gpuName":"no dGPU","powerW":0,"tempC":0,"util":0,"vramUsed":0,"vramTotal":0,"heldBy":""}' + exit 0 +fi + +DCARD=$(card_for "$DGPU_PCI") +ICARD=$(card_for "${IGPU_PCI:-}") +DREND=$(basename "$(readlink -f "/dev/dri/by-path/pci-$DGPU_PCI-render" 2>/dev/null)" 2>/dev/null) + +# dGPU runtime power state — reading this sysfs file does NOT wake the GPU. +runtime=$(cat "/sys/bus/pci/devices/$DGPU_PCI/power/runtime_status" 2>/dev/null || echo unknown) + +# Marketing name — resolved wake-free from sysfs + pci.ids (see gpu_name), and +# cached so even that lookup only runs once per session. +NAMECACHE="${XDG_RUNTIME_DIR:-/tmp}/nvidia-optimus.name" +if [ -s "$NAMECACHE" ]; then + gpuName=$(cat "$NAMECACHE") +else + gpuName=$(gpu_name) + printf '%s' "$gpuName" >"$NAMECACHE" 2>/dev/null || true +fi + +# External display: first connected non-eDP connector, and which GPU drives it. +external="none"; externalName="" +for st in /sys/class/drm/card*-*/status; do + conn=$(basename "$(dirname "$st")") + name=${conn#*-} + case "$name" in eDP*) continue;; esac + cardn=${conn%%-*} + # Reading a connector's status can force a probe that RESUMES the GPU. Skip the + # dGPU's own connectors while it is asleep, so monitoring never wakes it. (In HDMI + # mode the dGPU is already awake, so its connectors are read normally.) + [ "$cardn" = "${DCARD:-}" ] && [ "$runtime" != active ] && continue + [ "$(cat "$st" 2>/dev/null)" = connected ] || continue + pci=$(basename "$(readlink -f "/sys/class/drm/$cardn/device" 2>/dev/null)") + case "$pci" in + "$DGPU_PCI") external=NVIDIA ;; + "${IGPU_PCI:-none}") external=iGPU ;; + *) external="?" ;; + esac + externalName=$name + break +done + +# Live mode: is the compositor currently holding the dGPU render node? (lsof is safe.) +mode=battery +if [ -n "${DREND:-}" ] && lsof -t "/dev/dri/$DREND" 2>/dev/null \ + | while read -r pid; do ps -o comm= -p "$pid" 2>/dev/null; done \ + | grep -qiE '^(Hyprland|hyprpaper|quickshell)$'; then + mode=hdmi +fi + +# Pending mode: what env.conf will apply on the next Hyprland start. +pending=battery +grep -qE '^env = AQ_DRM_DEVICES,.*:' "$ENVFILE" 2>/dev/null && pending=hdmi + +# --- detail (nvidia-smi) — ONLY in full mode and ONLY when the GPU is awake --- +powerW=0; tempC=0; util=0; vramUsed=0; vramTotal=0; heldBy="" +if [ "$MODE_ARG" = full ] && [ "$runtime" = active ] && command -v nvidia-smi >/dev/null 2>&1; then + read -r pw tp ut vu vt < <(nvidia-smi \ + --query-gpu=power.draw,temperature.gpu,utilization.gpu,memory.used,memory.total \ + --format=csv,noheader,nounits 2>/dev/null | head -1 | tr -d ',') + powerW=${pw:-0}; tempC=${tp:-0}; util=${ut:-0} + vramUsed=$(awk "BEGIN{printf \"%.2f\", ${vu:-0}/1024}") + vramTotal=$(awk "BEGIN{printf \"%.2f\", ${vt:-0}/1024}") + # Every process with a context on the GPU — compute (C) AND graphics (G), + # including container/root procs that user-level lsof can't see. + heldBy=$(nvidia-smi 2>/dev/null | awk ' + /Processes:/ { p = 1 } + p && $5 ~ /^[0-9]+$/ && ($6 == "C" || $6 == "G") { + name = $7; for (i = 8; i <= NF - 2; i++) name = name " " $i; sub(/.*\//, "", name); + printf "%s%s:%s %s(%s)", (c++ ? ", " : ""), name, $5, $6, $(NF - 1); + }') +fi + +python3 - "$runtime" "$mode" "$pending" "$external" "$externalName" "$gpuName" \ + "$powerW" "$tempC" "$util" "$vramUsed" "$vramTotal" "$heldBy" <<'PY' +import json, sys +keys = ["runtime","mode","pending","external","externalName","gpuName", + "powerW","tempC","util","vramUsed","vramTotal","heldBy"] +d = dict(zip(keys, sys.argv[1:])) +for k in ("powerW","tempC","util","vramUsed","vramTotal"): + try: + d[k] = float(d[k]) + except Exception: + d[k] = 0 +print(json.dumps(d)) +PY diff --git a/nvidia-optimus/scripts/gpu-toggle b/nvidia-optimus/scripts/gpu-toggle new file mode 100755 index 000000000..2d3b335df --- /dev/null +++ b/nvidia-optimus/scripts/gpu-toggle @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# gpu-toggle — switch Hyprland's graphics between: +# battery iGPU only → the NVIDIA dGPU runtime-suspends (best battery, no HDMI) +# hdmi iGPU+dGPU → external outputs on the dGPU work; dGPU stays awake +# +# Battery mode also forces the session onto mesa-only EGL, because excluding the dGPU +# from AQ_DRM_DEVICES is not enough on Hyprland: the compositor + hyprpaper otherwise +# still open the nvidia nodes via EGL, keeping the dGPU out of RTD3 suspend. +# +# Card nodes are resolved from PCI addresses each run, so this is immune to +# /dev/dri/cardN renumbering. Changes apply on the next Hyprland start (log out/in). +# Usage: gpu-toggle [toggle|battery|hdmi|status|apply|info] +set -uo pipefail + +SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=gpu-detect.sh +. "$SELF_DIR/gpu-detect.sh" + +detect_gpus +ENVFILE="$(env_file)" +ICON="$SELF_DIR/../Assets/nvidia-eye.svg" + +notify() { notify-send -a "NVIDIA Optimus" -i "$ICON" "$@" 2>/dev/null || true; } + +ICARD=$(card_for "${IGPU_PCI:-}") +DCARD=$(card_for "${DGPU_PCI:-}") + +current() { grep -qE '^env = AQ_DRM_DEVICES,.*:' "$ENVFILE" 2>/dev/null && echo hdmi || echo battery; } + +set_mode() { # $1 = battery|hdmi + [ -f "$ENVFILE" ] || { notify -u critical "NVIDIA Optimus" "Hyprland env file not found: $ENVFILE"; exit 1; } + [ -n "${ICARD:-}" ] || { notify -u critical "NVIDIA Optimus" "Could not resolve the iGPU card node."; exit 1; } + local val + if [ "$1" = hdmi ]; then + [ -n "${DCARD:-}" ] || { notify -u critical "NVIDIA Optimus" "Could not resolve the NVIDIA dGPU card node."; exit 1; } + val="/dev/dri/$ICARD:/dev/dri/$DCARD" + else + val="/dev/dri/$ICARD" + fi + + cp -f "$ENVFILE" "$ENVFILE.nvidia-optimus.bak" + if grep -qE '^env = AQ_DRM_DEVICES,' "$ENVFILE"; then + sed -i -E "s|^env = AQ_DRM_DEVICES,.*|env = AQ_DRM_DEVICES,$val|" "$ENVFILE" + else + printf 'env = AQ_DRM_DEVICES,%s\n' "$val" >>"$ENVFILE" + fi + + # Manage the mesa-only EGL restriction (Battery mode only). + local egl="env = __EGL_VENDOR_LIBRARY_FILENAMES,$(mesa_egl_json)" + sed -i -E '/^env = __EGL_VENDOR_LIBRARY_FILENAMES,/d' "$ENVFILE" + if [ "$1" = battery ]; then + sed -i "/^env = AQ_DRM_DEVICES,/a $egl" "$ENVFILE" + fi +} + +banner() { # $1 = battery|hdmi + if [ "$1" = hdmi ]; then + notify "NVIDIA Optimus → HDMI-Ready" "dGPU will drive external displays. Log out & back in to apply." + else + notify "NVIDIA Optimus → Battery" "dGPU will sleep when idle (no HDMI). Log out & back in to apply." + fi +} + +case "${1:-toggle}" in + battery) set_mode battery; banner battery ;; + hdmi) set_mode hdmi; banner hdmi ;; + toggle|"") if [ "$(current)" = hdmi ]; then set_mode battery; banner battery; else set_mode hdmi; banner hdmi; fi ;; + status) current ;; + apply) notify "NVIDIA Optimus" "Restarting the session to apply…"; hyprctl dispatch exit ;; + info) notify "NVIDIA Optimus — pending: $(current)" "dGPU=${DCARD:-?} iGPU=${ICARD:-?}" ;; + *) echo "usage: gpu-toggle [toggle|battery|hdmi|status|apply|info]" >&2; exit 1 ;; +esac