Skip to content

Commit

Permalink
Add support for AppIndicator
Browse files Browse the repository at this point in the history
- Add entry in notification area
- Open menu on icon click in notification area
- Show notifications on errors if window is not visible

Signed-off-by: Pavel Artsishevsky <polter.rnd@gmail.com>
  • Loading branch information
polter-rnd committed Mar 18, 2023
1 parent bbb08e6 commit 1f83b26
Show file tree
Hide file tree
Showing 15 changed files with 257 additions and 60 deletions.
3 changes: 2 additions & 1 deletion bbswitch-gui.spec
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ BuildRequires: python3-py3nvml

Requires: bbswitchd
Requires: gtk3
Requires: libappindicator-gtk3
Requires: python3-gobject
Requires: python3-py3nvml
Requires: psmisc
Expand Down Expand Up @@ -48,7 +49,7 @@ without need to keep graphics adapter turned on all the time.
%license LICENSE
%{_bindir}/bbswitch-gui
%{python3_sitelib}/bbswitch_gui/*
%{_datadir}/applications/bbswitch-gui.desktop
%{_datadir}/applications/io.github.polter-rnd.bbswitch-gui.desktop
%{_datadir}/icons/hicolor/scalable/status/*-symbolic.svg
%{_datadir}/icons/hicolor/scalable/apps/bbswitch-gui.svg

Expand Down
150 changes: 132 additions & 18 deletions bbswitch_gui/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import time
import logging
import signal

from typing import Optional

Expand All @@ -12,16 +13,17 @@
from .pciutil import PCIUtil, PCIUtilException
from .bbswitch import BBswitchClient, BBswitchClientException
from .bbswitch import BBswitchMonitor, BBswitchMonitorException
from .nvidia import NvidiaMonitor, NvidiaMonitorException
from .nvidia import NVidiaGpuInfo, NvidiaMonitor, NvidiaMonitorException
from .window import MainWindow
from .indicator import Indicator

# Setup logger
logging.basicConfig(level=logging.INFO,
format='%(asctime)s %(name)s \033[1m%(levelname)s\033[0m %(message)s')
logger = logging.getLogger(__name__)

REFRESH_TIMEOUT = 1 # How often to refresh data, in seconds
MODULE_LOAD_TIMEOUT = 10 # Maximum time until warning will show, in seconds
REFRESH_TIMEOUT = 1 # How often to refresh nvidia monitor data, in seconds
MODULE_LOAD_TIMEOUT = 5 # How long to wait until nvidia module become accessible, in seconds


class Application(Gtk.Application):
Expand All @@ -44,15 +46,33 @@ def __init__(self, *args, **kwargs) -> None:
'Enable debug logging',
None,
)
self.add_main_option(
'minimize',
ord('m'),
GLib.OptionFlags.NONE,
GLib.OptionArg.NONE,
'Minimize to system tray',
None,
)

self._enabled_gpu: Optional[str] = None
self._bg_notification_shown = False

self.gpu_info: Optional[NVidiaGpuInfo] = None
self.window: Optional[MainWindow] = None
self.indicator: Optional[Indicator] = None

self.bbswitch = BBswitchMonitor()
self.client = BBswitchClient()
self.nvidia = NvidiaMonitor(timeout=REFRESH_TIMEOUT)

def update_bbswitch(self) -> None:
"""Update GPU state from `bbswitch` module."""
logging.debug('Got update from bbswitch')

if self.indicator:
self.indicator.reset()

if self.window:
self.window.reset()

Expand All @@ -63,20 +83,28 @@ def update_bbswitch(self) -> None:
_, device = PCIUtil.get_device_info(PCIUtil.get_vendor_id(bus_id),
PCIUtil.get_device_id(bus_id))
except BBswitchMonitorException as err:
logger.error(err)
message = str(err)
logger.error(message)
self.nvidia.monitor_stop()
if self.window:
self.window.show_error(str(err))
self.window.show_error(message)
if not self.window or not self.window.is_active():
self._notify_error('BBswitch monitor error', message)
return
except PCIUtilException as err:
logger.warning(err)

if self.indicator:
self.indicator.set_state(enabled)

if self.window:
self.window.update_header(bus_id, enabled, device)

if enabled:
logger.debug('Adapter %s is ON', bus_id)
self.nvidia.monitor_start(self.update_nvidia, bus_id, time.monotonic())
self._enabled_gpu = bus_id
if self.window and self.window.is_visible():
self.nvidia.monitor_start(self.update_nvidia, bus_id, time.monotonic())
else:
logger.debug('Adapter %s is OFF', bus_id)
self.nvidia.monitor_stop()
Expand All @@ -89,9 +117,9 @@ def update_nvidia(self, bus_id: str, enabled_ts: float) -> None:
logging.debug('Got update from nvidia-smi')

try:
info = self.nvidia.gpu_info(bus_id)
self.gpu_info = self.nvidia.gpu_info(bus_id)
if self.window:
if info is None:
if self.gpu_info is None:
# None return value means no kernel modules available
if time.monotonic() - enabled_ts > MODULE_LOAD_TIMEOUT:
# If it took really long time, display warning
Expand All @@ -102,28 +130,56 @@ def update_nvidia(self, bus_id: str, enabled_ts: float) -> None:
# Otherwise it's normal, loading modules can take some time
self.window.show_info('Loading NVIDIA kernel modules...')
else:
self.window.update_monitor(info)
self.window.update_monitor(self.gpu_info)
except NvidiaMonitorException as err:
message = str(err)
logger.error(message)
if self.window:
self.window.show_error(message)
if not self.window or not self.window.is_active():
self._notify_error('NVIDIA monitor error', message)

def do_startup(self, *args, **kwargs) -> None:
"""Handle application startup."""
Gtk.Application.do_startup(self)

action = Gio.SimpleAction.new('activate', None)
action.connect('activate', self._on_activate) # type: ignore
self.add_action(action) # type: ignore

action = Gio.SimpleAction.new('exit', None)
action.connect('activate', self._on_quit) # type: ignore
self.add_action(action) # type: ignore

GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGINT, self._on_quit)

def do_activate(self, *args, **kwargs) -> None:
"""Initialize GUI.
We only allow a single window and raise any existing ones
"""
if not self.indicator:
self.indicator = Indicator()
self.indicator.connect('open-requested', self._on_activate)
self.indicator.connect('exit-requested', self._on_quit)
self.indicator.connect('power-state-switch-requested', self._on_state_switch)

if not self.window:
self.window = MainWindow(self)
self.window.connect('power-state-switch-requested', self._on_state_switch)
self.bbswitch.monitor_start(self.update_bbswitch)
self.window.connect('delete-event', self._on_window_close)
self.window.connect('show', self._on_window_show)
self.window.connect('hide', self._on_window_hide)

self.window.present()
# Ping server so it will load bbswitch module
try:
self.client.send_command('status')
except BBswitchClientException as err:
logging.error(err)

self.bbswitch.monitor_start(self.update_bbswitch)
else:
self.window.present()

def do_command_line(self, *args: Gio.ApplicationCommandLine, **kwargs) -> int:
"""Handle command line arguments.
Expand All @@ -141,25 +197,83 @@ def do_command_line(self, *args: Gio.ApplicationCommandLine, **kwargs) -> int:
logger.debug('Verbose output enabled')

self.activate()

if self.window:
if 'minimize' in options:
self.window.hide()
else:
self.window.show()

return 0

def _on_state_switch_finish(self, error: Optional[BBswitchClientException] = None):
def _on_activate(self, widget=None, data=None):
del widget, data # unused arguments
self.do_activate()
return GLib.SOURCE_CONTINUE

def _on_quit(self, widget=None, data=None):
del widget, data # unused arguments
self.quit()
return GLib.SOURCE_REMOVE

def _on_state_switch_finish(self, error):
if error is not None:
logger.error(error)
logger.error(str(error))
self.update_bbswitch()
if self.window:
self.window.error_dialog('Failed to switch power state', str(error))
self._notify_error('Failed to switch power state', str(error))
if self.window:
self.window.set_cursor_arrow()

def _on_state_switch(self, window: MainWindow, state: bool):
def _on_state_switch(self, widget, state):
del widget # unused argument
if self.client.in_progress():
self.client.cancel()
return

# Save timestamp when power state change was requested
self._state_switched_ts = time.monotonic()
if self.gpu_info and len(self.gpu_info['processes']) > 0:
if self.window:
self._notify_error('NVIDIA GPU is in use',
'Please stop processes using it first')
return

# Switch to opposite state
self.client.set_gpu_state(state, self._on_state_switch_finish)
window.set_cursor_busy()
if self.window:
self.window.set_cursor_busy()

def _notify_error(self, title, message):
if self.window and self.window.is_active():
self.window.error_dialog(title, message)
else:
notification = Gio.Notification()
notification.set_title(title)
notification.set_body(message)
notification.set_default_action('app.activate')
notification.add_button('Open Window', 'app.activate')
self.send_notification('', notification)

def _on_window_show(self, window):
del window # unused argument
self.withdraw_notification('')
if self._enabled_gpu:
self.nvidia.monitor_start(self.update_nvidia,
self._enabled_gpu,
time.monotonic())

def _on_window_hide(self, window):
del window # unused argument
self.nvidia.monitor_stop()

def _on_window_close(self, window, event):
del event # unused argument
if not self._bg_notification_shown:
notification = Gio.Notification()
notification.set_title('BBswitch GUI stays in background')
notification.set_body('Could be accessed through system tray')
notification.set_default_action('app.activate')
notification.add_button('Open Window', 'app.activate')
notification.add_button('Exit', 'app.exit')
self.send_notification('', notification)
self._bg_notification_shown = True
return window.hide_on_delete()
2 changes: 1 addition & 1 deletion bbswitch_gui/bbswitch.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,4 +180,4 @@ def _on_monitor_event_changed(self, monitor, file, other_file, event_type):
# Do not call a callback if monitor has been stopped
if self.callback is not None:
self.callback(*self.callback_args)
return True
return GLib.SOURCE_CONTINUE
84 changes: 84 additions & 0 deletions bbswitch_gui/indicator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""Module containing tray indicator."""

import gi
gi.require_version('Gtk', '3.0')
gi.require_version('AppIndicator3', '0.1')
from gi.repository import GObject, Gtk, AppIndicator3 # pyright: ignore


class Indicator(GObject.GObject):
"""Tray Indicator."""

__gtype_name__ = "Indicator"
__gsignals__ = {
'open-requested': (GObject.SIGNAL_RUN_LAST,
GObject.TYPE_NONE, ()),
'exit-requested': (GObject.SIGNAL_RUN_LAST,
GObject.TYPE_NONE, ()),
'power-state-switch-requested': (GObject.SIGNAL_RUN_LAST,
GObject.TYPE_NONE, (bool,))
}

def __init__(self, **kwargs) -> None:
"""Initialize Tray Indicator."""
super().__init__(**kwargs)

self._app_indicator = AppIndicator3.Indicator.new(
'customtray', '',
AppIndicator3.IndicatorCategory.HARDWARE)
self._app_indicator.set_status(AppIndicator3.IndicatorStatus.ACTIVE)
self._app_indicator.set_menu(self._menu())
self._enabled = False

def reset(self) -> None:
"""Reset indicator to default state."""
self.set_state(False)
self._switch_item.set_sensitive(False)

def set_state(self, enabled: bool) -> None:
"""Set power state of dedicated GPU."""
self._enabled = enabled
self._app_indicator.set_icon('bbswitch-tray-active-symbolic' if enabled else
'bbswitch-tray-symbolic')
self._app_indicator.set_title('Giscrete GPU: On' if enabled else
'Giscrete GPU: Off')
self._switch_item.set_label('Turn GPU Off' if enabled else
'Turn GPU On')
self._switch_item.set_image(Gtk.Image.new_from_icon_name(
'bbswitch-on-symbolic' if enabled else
'bbswitch-off-symbolic',
Gtk.IconSize.MENU)) # type: ignore
self._switch_item.set_sensitive(True)

def _menu(self):
menu = Gtk.Menu()

self._switch_item = Gtk.ImageMenuItem()
self._switch_item.set_always_show_image(True) # type: ignore
self._switch_item.connect('activate', self._request_power_state_switch)
menu.append(self._switch_item)

menu.append(Gtk.SeparatorMenuItem())

self.open_item = Gtk.MenuItem('Open Window')
self.open_item.connect('activate', self._request_open)
menu.append(self.open_item)

close_item = Gtk.MenuItem('Exit')
close_item.connect('activate', self._request_exit)
menu.append(close_item)

menu.show_all()
return menu

def _request_open(self, menuitem):
del menuitem # unused argument
self.emit('open-requested')

def _request_exit(self, menuitem):
del menuitem # unused argument
self.emit('exit-requested')

def _request_power_state_switch(self, menuitem):
del menuitem # unused argument
self.emit('power-state-switch-requested', not self._enabled)
3 changes: 2 additions & 1 deletion bbswitch_gui/meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ py_sources = [
'nvidia.py',
'pciutil.py',
'psutil.py',
'window.py'
'window.py',
'indicator.py'
]
python.install_sources(py_sources,
subdir : 'bbswitch_gui'
Expand Down
4 changes: 2 additions & 2 deletions bbswitch_gui/nvidia.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import logging
from typing import Any, Callable, List, TypedDict, Optional, Tuple
from gi.repository import GObject # pyright: ignore
from gi.repository import GLib, GObject # pyright: ignore

try:
import pynvml # pyright: ignore
Expand Down Expand Up @@ -209,4 +209,4 @@ def _timer_callback(self):
# Do not call a callback if timer has been stopped
if self.timer is not None and self.callback is not None:
self.callback(*self.callback_args)
return True
return GLib.SOURCE_CONTINUE

0 comments on commit 1f83b26

Please sign in to comment.