Add active GPU indicator and dGPU process viewer in hybrid mode
In hybrid mode, the tray icon chip body now fills red (iGPU active) or green (dGPU active) based on whether any process has /dev/nvidia0 open, polled every 5s. Uses fuser instead of runtime_status because nvidia-drm.modeset=1 keeps the PCI device active regardless of user-space usage. Also adds a "Show dGPU processes" menu item (nvidia-smi, hybrid mode only). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5e45d157d1
commit
b03f0a182d
2 changed files with 62 additions and 4 deletions
|
|
@ -4,11 +4,13 @@ A lightweight KDE system tray application for switching GPU modes on Lenovo Legi
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Shows current GPU mode in the system tray (chip icon: red/green split for Hybrid, solid green for NVIDIA only)
|
- Shows current GPU mode in the system tray (chip icon: red/green split outline for Hybrid, solid green for NVIDIA only)
|
||||||
|
- **In Hybrid mode**, the chip body fills red (iGPU active) or green (dGPU active) to show which GPU is currently doing work — updates every 5 seconds via `/dev/nvidia0` usage detection
|
||||||
- Switch between **Hybrid** (AMD iGPU drives internal display, NVIDIA exposes HDMI) and **NVIDIA only** (dGPU drives everything) with one click
|
- Switch between **Hybrid** (AMD iGPU drives internal display, NVIDIA exposes HDMI) and **NVIDIA only** (dGPU drives everything) with one click
|
||||||
- No password prompt for the active desktop session (polkit `allow_active: yes`)
|
- No password prompt for the active desktop session (polkit `allow_active: yes`)
|
||||||
- Tracks pending mode switches before reboot — shows an orange badge on the icon and allows cancelling the switch
|
- Tracks pending mode switches before reboot — shows an orange badge on the icon and allows cancelling the switch
|
||||||
- Triggers a reboot prompt after switching; also offers "Reboot now" from the tray menu
|
- Triggers a reboot prompt after switching; also offers "Reboot now" from the tray menu
|
||||||
|
- **In Hybrid mode**, shows a "Show dGPU processes" menu item listing which processes are currently using the dGPU (via `nvidia-smi`)
|
||||||
- Installs itself as a KDE autostart entry
|
- Installs itself as a KDE autostart entry
|
||||||
|
|
||||||
## Compatibility
|
## Compatibility
|
||||||
|
|
|
||||||
62
main.py
62
main.py
|
|
@ -22,6 +22,21 @@ GREY = QColor('#9E9E9E')
|
||||||
BADGE = QColor('#FFA726')
|
BADGE = QColor('#FFA726')
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_gpu():
|
||||||
|
"""Returns 'dgpu' if any user process has /dev/nvidia0 open, else 'igpu'.
|
||||||
|
runtime_status is unreliable here because nvidia-drm.modeset=1 keeps the
|
||||||
|
PCI device active regardless of user-space usage."""
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
['fuser', '/dev/nvidia0'],
|
||||||
|
capture_output=True, timeout=3,
|
||||||
|
)
|
||||||
|
return 'dgpu' if r.returncode == 0 else 'igpu'
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return 'igpu'
|
||||||
|
|
||||||
|
|
||||||
def get_mode():
|
def get_mode():
|
||||||
try:
|
try:
|
||||||
r = subprocess.run(
|
r = subprocess.run(
|
||||||
|
|
@ -73,7 +88,7 @@ def _draw_chip(painter, color, clip=None):
|
||||||
painter.restore()
|
painter.restore()
|
||||||
|
|
||||||
|
|
||||||
def make_icon(mode, pending=False):
|
def make_icon(mode, pending=False, active_gpu='igpu'):
|
||||||
size = 22
|
size = 22
|
||||||
px = QPixmap(size, size)
|
px = QPixmap(size, size)
|
||||||
px.fill(Qt.GlobalColor.transparent)
|
px.fill(Qt.GlobalColor.transparent)
|
||||||
|
|
@ -81,6 +96,11 @@ def make_icon(mode, pending=False):
|
||||||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
|
|
||||||
if mode == 'hybrid':
|
if mode == 'hybrid':
|
||||||
|
fill = QColor(NV_GREEN if active_gpu == 'dgpu' else AMD_RED)
|
||||||
|
fill.setAlpha(90)
|
||||||
|
p.setPen(Qt.PenStyle.NoPen)
|
||||||
|
p.setBrush(fill)
|
||||||
|
p.drawRoundedRect(5, 5, 12, 12, 2, 2)
|
||||||
_draw_chip(p, AMD_RED, QRect(0, 0, 11, size))
|
_draw_chip(p, AMD_RED, QRect(0, 0, 11, size))
|
||||||
_draw_chip(p, NV_GREEN, QRect(11, 0, 11, size))
|
_draw_chip(p, NV_GREEN, QRect(11, 0, 11, size))
|
||||||
elif mode == 'dgpu':
|
elif mode == 'dgpu':
|
||||||
|
|
@ -104,7 +124,8 @@ class GpuModeTray:
|
||||||
|
|
||||||
self.active_mode = get_mode()
|
self.active_mode = get_mode()
|
||||||
self.pending_mode = None
|
self.pending_mode = None
|
||||||
self.tray = QSystemTrayIcon(make_icon(self.active_mode))
|
self.active_gpu = get_active_gpu() if self.active_mode == 'hybrid' else None
|
||||||
|
self.tray = QSystemTrayIcon(make_icon(self.active_mode, active_gpu=self.active_gpu))
|
||||||
self._build_menu()
|
self._build_menu()
|
||||||
self.tray.setContextMenu(self.menu)
|
self.tray.setContextMenu(self.menu)
|
||||||
self.tray.show()
|
self.tray.show()
|
||||||
|
|
@ -113,6 +134,10 @@ class GpuModeTray:
|
||||||
self.timer.timeout.connect(self._refresh)
|
self.timer.timeout.connect(self._refresh)
|
||||||
self.timer.start(30_000)
|
self.timer.start(30_000)
|
||||||
|
|
||||||
|
self.gpu_timer = QTimer()
|
||||||
|
self.gpu_timer.timeout.connect(self._refresh_active_gpu)
|
||||||
|
self.gpu_timer.start(5_000)
|
||||||
|
|
||||||
def _build_menu(self):
|
def _build_menu(self):
|
||||||
self.menu = QMenu()
|
self.menu = QMenu()
|
||||||
|
|
||||||
|
|
@ -129,6 +154,11 @@ class GpuModeTray:
|
||||||
self.dgpu_action.triggered.connect(lambda: self._request_switch('dgpu'))
|
self.dgpu_action.triggered.connect(lambda: self._request_switch('dgpu'))
|
||||||
self.menu.addAction(self.dgpu_action)
|
self.menu.addAction(self.dgpu_action)
|
||||||
|
|
||||||
|
self.sep_dgpu_procs = self.menu.addSeparator()
|
||||||
|
self.dgpu_procs_action = QAction('Show dGPU processes')
|
||||||
|
self.dgpu_procs_action.triggered.connect(self._show_dgpu_processes)
|
||||||
|
self.menu.addAction(self.dgpu_procs_action)
|
||||||
|
|
||||||
self.sep_pending = self.menu.addSeparator()
|
self.sep_pending = self.menu.addSeparator()
|
||||||
self.cancel_action = QAction()
|
self.cancel_action = QAction()
|
||||||
self.cancel_action.triggered.connect(self._cancel_switch)
|
self.cancel_action.triggered.connect(self._cancel_switch)
|
||||||
|
|
@ -157,7 +187,7 @@ class GpuModeTray:
|
||||||
self.status_item.setText(f'GPU mode: {active_label}')
|
self.status_item.setText(f'GPU mode: {active_label}')
|
||||||
self.tray.setToolTip(f'GPU mode: {active_label}')
|
self.tray.setToolTip(f'GPU mode: {active_label}')
|
||||||
|
|
||||||
self.tray.setIcon(make_icon(self.active_mode, pending=pending))
|
self.tray.setIcon(make_icon(self.active_mode, pending=pending, active_gpu=self.active_gpu))
|
||||||
|
|
||||||
# Switch action labels and enabled state
|
# Switch action labels and enabled state
|
||||||
for key, action in [('hybrid', self.hybrid_action), ('dgpu', self.dgpu_action)]:
|
for key, action in [('hybrid', self.hybrid_action), ('dgpu', self.dgpu_action)]:
|
||||||
|
|
@ -171,16 +201,42 @@ class GpuModeTray:
|
||||||
action.setText(SWITCH_LABELS[key])
|
action.setText(SWITCH_LABELS[key])
|
||||||
action.setEnabled(self.active_mode != key)
|
action.setEnabled(self.active_mode != key)
|
||||||
|
|
||||||
|
in_hybrid = self.active_mode == 'hybrid'
|
||||||
|
self.sep_dgpu_procs.setVisible(in_hybrid)
|
||||||
|
self.dgpu_procs_action.setVisible(in_hybrid)
|
||||||
|
|
||||||
self.sep_pending.setVisible(pending)
|
self.sep_pending.setVisible(pending)
|
||||||
self.cancel_action.setVisible(pending)
|
self.cancel_action.setVisible(pending)
|
||||||
self.reboot_action.setVisible(pending)
|
self.reboot_action.setVisible(pending)
|
||||||
if pending:
|
if pending:
|
||||||
self.cancel_action.setText(f'Cancel switch to {MODE_LABELS[self.pending_mode]}')
|
self.cancel_action.setText(f'Cancel switch to {MODE_LABELS[self.pending_mode]}')
|
||||||
|
|
||||||
|
def _show_dgpu_processes(self):
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
['nvidia-smi', '--query-compute-apps=pid,name,used_memory', '--format=csv,noheader'],
|
||||||
|
capture_output=True, text=True, timeout=5,
|
||||||
|
)
|
||||||
|
text = r.stdout.strip()
|
||||||
|
if not text:
|
||||||
|
text = 'No processes currently using the dGPU.'
|
||||||
|
except Exception as e:
|
||||||
|
text = f'Failed to query nvidia-smi:\n{e}'
|
||||||
|
QMessageBox.information(None, 'dGPU processes', text)
|
||||||
|
|
||||||
|
def _refresh_active_gpu(self):
|
||||||
|
if self.active_mode != 'hybrid':
|
||||||
|
return
|
||||||
|
gpu = get_active_gpu()
|
||||||
|
if gpu != self.active_gpu:
|
||||||
|
self.active_gpu = gpu
|
||||||
|
self._sync_ui()
|
||||||
|
|
||||||
def _refresh(self):
|
def _refresh(self):
|
||||||
mode = get_mode()
|
mode = get_mode()
|
||||||
if mode != self.active_mode:
|
if mode != self.active_mode:
|
||||||
self.active_mode = mode
|
self.active_mode = mode
|
||||||
|
self.active_gpu = get_active_gpu() if mode == 'hybrid' else None
|
||||||
self.pending_mode = None
|
self.pending_mode = None
|
||||||
self._sync_ui()
|
self._sync_ui()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue