diff --git a/README.md b/README.md index 25e487c..bbeb358 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # amdgpu_stats -A simple Python module/TUI for AMD GPU statistics +A Python module/TUI for AMD GPU statistics ![Screenshot of main screen](https://raw.githubusercontent.com/joshlay/amdgpu_stats/master/screens/main.png "Main screen") @@ -35,7 +35,7 @@ For more information on what the module provides, please see: - `help('amdgpu_stats.utils')` in your interpreter - [The module source](https://github.com/joshlay/amdgpu_stats/blob/master/src/amdgpu_stats/utils.py) -Feature requests [are encouraged](https://github.com/joshlay/amdgpu_stats/issues) :) +Feature requests [are encouraged](https://github.com/joshlay/amdgpu_stats/issues) 😀 ## Requirements Only `Linux` is supported. Information is _completely_ sourced from interfaces in `sysfs`. diff --git a/pyproject.toml b/pyproject.toml index 077f7fc..986b460 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [tool.poetry] name = "amdgpu-stats" -version = "0.1.10" -description = "A simple module/TUI for AMD GPU statistics" +version = "0.1.11" +description = "A module/TUI for AMD GPU statistics" authors = ["Josh Lay "] repository = "https://github.com/joshlay/amdgpu_stats" license = "MIT" @@ -14,7 +14,7 @@ textual = ">=0.10" humanfriendly = ">=10.0" [tool.poetry.scripts] -amdgpu-stats = "amdgpu_stats.interface:tui" +amdgpu-stats = "amdgpu_stats.tui:start" [build-system] requires = ["poetry-core"] diff --git a/screens/logging.png b/screens/logging.png index 7bb3e52..6b597c2 100644 Binary files a/screens/logging.png and b/screens/logging.png differ diff --git a/screens/main.png b/screens/main.png index b1e608f..ec3f6f0 100644 Binary files a/screens/main.png and b/screens/main.png differ diff --git a/src/amdgpu_stats/__main__.py b/src/amdgpu_stats/__main__.py new file mode 100644 index 0000000..cde54a6 --- /dev/null +++ b/src/amdgpu_stats/__main__.py @@ -0,0 +1,13 @@ +"""TUI for amdgpu_stats + +This file aims to ensure the TUI only starts in interactive shells""" +from .tui import start + + +def main(): + """main function, spawns the TUI for amdgpu_stats""" + start() + + +if __name__ == "__main__": + main() diff --git a/src/amdgpu_stats/amdgpu_stats.css b/src/amdgpu_stats/amdgpu_stats.css deleted file mode 100644 index 9309d89..0000000 --- a/src/amdgpu_stats/amdgpu_stats.css +++ /dev/null @@ -1,23 +0,0 @@ -GPUStatsWidget { - layout: grid; - grid-size: 3; - grid-gutter: 2 12; - background: $boost; - height: 11; - min-width: 50; - padding: 1; -} - -/* for colors, see: https://textual.textualize.io/guide/design/#theme-reference */ - -.box { - height: 100%; -/* border: ascii $primary-background;*/ - border: solid $primary-background; - content-align: center middle; - padding: 0 1; -} - -.statvalue { - dock: right; -} diff --git a/src/amdgpu_stats/amdgpu_stats.py b/src/amdgpu_stats/amdgpu_stats.py deleted file mode 100755 index 61397a8..0000000 --- a/src/amdgpu_stats/amdgpu_stats.py +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/python3 -"""Pretty Textual-based stats for AMD GPUs - -TODO: restore argparse / --card, in case detection fails. - will require separating the hwmon finding tasks from 'find_card' - -rich markup reference: - https://rich.readthedocs.io/en/stable/markup.html -""" - -if __name__ == "__main__": - from .interface import tui - tui() diff --git a/src/amdgpu_stats/style.css b/src/amdgpu_stats/style.css new file mode 100644 index 0000000..b7b5d41 --- /dev/null +++ b/src/amdgpu_stats/style.css @@ -0,0 +1,38 @@ +Header { + background: $panel; +} + +GPUStatsWidget { + layout: grid; + grid-size: 3; + grid-gutter: 2 8; + box-sizing: content-box; + background: $panel; + height: 10; + min-width: 50; +} + +/* for colors, see: https://textual.textualize.io/guide/design/#theme-reference */ + +.widgetheader { + background: $panel; + border: solid $primary-background; + content-align: center middle; +} + +.box { + height: 100%; +/* border: ascii $primary-background;*/ + content-align: center middle; + padding: 0 1; +} + +Horizontal { + padding: 0; + margin: 0; + box-sizing: content-box; +} + +.statvalue { + dock: right; +} diff --git a/src/amdgpu_stats/interface.py b/src/amdgpu_stats/tui.py similarity index 67% rename from src/amdgpu_stats/interface.py rename to src/amdgpu_stats/tui.py index 50fbce5..732ae96 100644 --- a/src/amdgpu_stats/interface.py +++ b/src/amdgpu_stats/tui.py @@ -1,7 +1,9 @@ """ -interface.py +tui.py -This module provides the TUI aspect of 'amdgpu-stats' +This file provides the user interface of `amdgpu-stats` + +Can be used as a way to monitor GPU(s) in your terminal, or inform other utilities. Classes: - GPUStats: the object for the _Application_, instantiated at runtime @@ -12,9 +14,13 @@ Classes: - LogScreen: Second screen with the logging widget, header, and footer Functions: - - tui: Renders the TUI using the classes above + - start: Creates the 'App' and renders the TUI using the classes above """ +# disable superfluouos linting +# pylint: disable=line-too-long import sys +from datetime import datetime +from os import path from textual.binding import Binding from textual.app import App, ComposeResult @@ -25,11 +31,8 @@ from textual.widgets import Header, Footer, Static, TextLog, Label from .utils import AMDGPU_CARDS, format_frequency, get_core_stats, get_fan_rpm, get_fan_target, get_power_stats, get_temp_stats # pylint: disable=line-too-long -# globals - card handling / choice for TUI -if len(AMDGPU_CARDS) > 0: - # default to showing stats for the first detected card - CARD = next(iter(AMDGPU_CARDS)) - hwmon_dir = AMDGPU_CARDS[CARD] +# rich markup reference: +# https://rich.readthedocs.io/en/stable/markup.html class LogScreen(Screen): @@ -44,7 +47,7 @@ class LogScreen(Screen): On first display in this case.""" def compose(self) -> ComposeResult: - yield Header() + yield Header(show_clock=True) yield Container(self.text_log) yield Footer() @@ -56,36 +59,57 @@ class LogScreen(Screen): class GPUStatsWidget(Static): """The main stats widget.""" + def __init__(self, *args, card=None, **kwargs): + super().__init__(*args, **kwargs) + # Instance variables + self.card = card + self.hwmon_dir = AMDGPU_CARDS[self.card] + def compose(self) -> ComposeResult: """Create child widgets.""" - yield ClockDisplay(classes="box") - yield PowerDisplay(classes="box") - yield MiscDisplay(classes="box") + yield ClockDisplay(classes="box", card=self.card, hwmon_dir=self.hwmon_dir) + yield PowerDisplay(classes="box", card=self.card, hwmon_dir=self.hwmon_dir) + yield MiscDisplay(classes="box", card=self.card) + _msg = f'''[bold]App:[/] creating stat widgets for [green]{self.card}[/], stats directory: {self.hwmon_dir}''' + self.update_log(_msg) + + def update_log(self, message: str) -> None: + """Update the TextLog widget with a new message.""" + log_screen = AMDGPUStats.SCREENS["logs"] + log_screen.text_log.write(message) -class GPUStats(App): +class AMDGPUStats(App): """Textual-based tool to show AMDGPU statistics.""" # apply stylesheet - CSS_PATH = 'amdgpu_stats.css' + CSS_PATH = 'style.css' # initialize log screen SCREENS = {"logs": LogScreen()} + # title the app after the card + # TITLE = 'GPUStats - ' + CARD + # setup keybinds # Binding("l", "push_screen('logs')", "Toggle logs", priority=True), BINDINGS = [ - Binding("c", "toggle_dark", "Toggle colors", priority=True), - Binding("l", "toggle_log", "Toggle logs", priority=True), - Binding("q", "quit_app", "Quit", priority=True) + Binding("c", "custom_dark", "Colors"), + Binding("l", "toggle_log", "Logs"), + Binding("s", "screenshot_wrapper", "Screenshot"), + Binding("q", "quit", "Quit") ] def compose(self) -> ComposeResult: """Create child widgets for the app.""" - yield Header() - yield Container(GPUStatsWidget()) + yield Header(show_clock=True) + for card in AMDGPU_CARDS: + stat_widget_name = "stats_" + card + yield Label(card, expand=True, classes='widgetheader') + widget = Container(GPUStatsWidget(card=card, id=stat_widget_name)) + yield widget self.update_log("[bold green]App started, logging begin!") - self.update_log(f"[bold italic]Information source:[/] {hwmon_dir}") + self.update_log(f"[bold]Discovered AMD GPUs:[/] {list(AMDGPU_CARDS)}") # nice-to-have: account for not storing these in dicts, but resolved in funcs # for metric, source in SRC_FILES.items(): # self.update_log(f'[bold] {metric}:[/] {source}') @@ -93,16 +117,23 @@ class GPUStats(App): # self.update_log(f'[bold] {metric} temperature:[/] {source}') yield Footer() - def action_toggle_dark(self) -> None: - """An action to toggle dark mode.""" + async def action_custom_dark(self) -> None: + """An action to toggle dark mode. + + Wraps 'action_toggle_dark' with logging and a refresh""" self.dark = not self.dark self.update_log(f"[bold]Dark side: [italic]{self.dark}") + self.refresh() + # self.dark = not self.dark - def action_quit_app(self) -> None: - """An action to quit the program""" - message = "Exiting on user request" - self.update_log(f"[bold]{message}") - self.exit(message) + def action_screenshot_wrapper(self, screen_dir: str = '/tmp') -> None: + """Action that fires when the user presses 's' for a screenshot""" + # construct the screenshot elements + path + timestamp = datetime.now().isoformat().replace(":", "_") + screen_name = 'amdgpu_stats_' + timestamp + '.svg' + screen_path = path.join(screen_dir, screen_name) + self.action_screenshot(path=screen_dir, filename=screen_name) + self.update_log(f'[bold]Screenshot taken: [italic]{screen_path}') def action_toggle_log(self) -> None: """Toggle between the main screen and the LogScreen.""" @@ -125,7 +156,6 @@ class MiscDisplay(Static): fan_rpm_target = reactive(0) # do some dancing to craft the UI; initialize the reactive obj with data # to get proper labels - initial_stats = get_temp_stats(CARD) # dynamic object for temperature updates temp_stats = reactive({}) # default to 'not composed', once labels are made - become true @@ -133,10 +163,12 @@ class MiscDisplay(Static): # ... and making labels for them composed = False - def __init__(self, *args, **kwargs): + def __init__(self, card: str, *args, **kwargs): super().__init__(*args, **kwargs) self.timer_misc = None - self.temp_stats = get_temp_stats(CARD) + self.card = card + self.initial_stats = get_temp_stats(self.card) + self.temp_stats = get_temp_stats(self.card) def compose(self) -> ComposeResult: yield Horizontal(Label("[underline]Temperatures"), @@ -144,14 +176,15 @@ class MiscDisplay(Static): for temp_node in self.initial_stats: # capitalize the first letter for display caption = temp_node[0].upper() + temp_node[1:] - yield Horizontal(Label(f' {caption}:',), - Label("", id="temp_" + temp_node, - classes="statvalue")) + yield Horizontal(Label(f' {caption}:',), + Label("", id="temp_" + temp_node, classes="statvalue")) + # padding to split groups + yield Horizontal() yield Horizontal(Label("[underline]Fan RPM"), Label("", classes="statvalue")) - yield Horizontal(Label(" Current:",), + yield Horizontal(Label(" Current:",), Label("", id="fan_rpm", classes="statvalue")) - yield Horizontal(Label(" Target:",), + yield Horizontal(Label(" Target:",), Label("", id="fan_rpm_target", classes="statvalue")) self.composed = True @@ -163,9 +196,9 @@ class MiscDisplay(Static): """Method to update the temp/fan values to current measurements. Run by a timer created 'on_mount'""" - self.fan_rpm = get_fan_rpm(CARD) - self.fan_rpm_target = get_fan_target(CARD) - self.temp_stats = get_temp_stats(CARD) + self.fan_rpm = get_fan_rpm(self.card) + self.fan_rpm_target = get_fan_target(self.card) + self.temp_stats = get_temp_stats(self.card) def watch_fan_rpm(self, fan_rpm: int) -> None: """Called when the 'fan_rpm' reactive attr changes. @@ -195,25 +228,28 @@ class ClockDisplay(Static): """A widget to display GPU power stats.""" core_vals = reactive({"sclk": 0, "mclk": 0, "voltage": 0, "util_pct": 0}) - def __init__(self, *args, **kwargs): + def __init__(self, card: str, hwmon_dir: str, *args, **kwargs): super().__init__(*args, **kwargs) self.timer_clocks = None + self.card = card + self.hwmon_dir = hwmon_dir def compose(self) -> ComposeResult: - yield Horizontal(Label("[underline]Clocks"), + yield Horizontal(Label("[underline]Performance"), Label("", classes="statvalue")) - yield Horizontal(Label(" GPU core:",), + yield Horizontal(Label(" Core clock:",), Label("", id="clk_core_val", classes="statvalue")) - yield Horizontal(Label(" Memory:"), + yield Horizontal(Label(" Memory clock:"), Label("", id="clk_memory_val", classes="statvalue")) - # padding to split groups - yield Horizontal(Label(""), Label("", classes="statvalue")) - yield Horizontal(Label("[underline]Core"), - Label("", classes="statvalue")) - yield Horizontal(Label(" Utilization:",), + yield Horizontal(Label(" Utilization:",), Label("", id="util_pct", classes="statvalue")) - yield Horizontal(Label(" Voltage:",), + yield Horizontal(Label(" Voltage:",), Label("", id="clk_voltage_val", classes="statvalue")) + # padding underneath, don't let them space out vertically + yield Horizontal() + yield Horizontal() + yield Horizontal() + yield Horizontal() def on_mount(self) -> None: """Event handler called when widget is added to the app.""" @@ -222,7 +258,7 @@ class ClockDisplay(Static): def update_core_vals(self) -> None: """Method to update GPU clock values to the current measurements. Run by a timer created 'on_mount'""" - self.core_vals = get_core_stats(CARD) + self.core_vals = get_core_stats(self.card) def watch_core_vals(self, core_vals: dict) -> None: """Called when the clocks attribute changes @@ -246,25 +282,27 @@ class PowerDisplay(Static): "capability": 0, "default": 0}) - def __init__(self, *args, **kwargs): + def __init__(self, card: str, hwmon_dir: str, *args, **kwargs): super().__init__(*args, **kwargs) self.timer_watts = None + self.card = card + self.hwmon_dir = hwmon_dir def compose(self) -> ComposeResult: yield Horizontal(Label("[underline]Power"), Label("", classes="statvalue")) - yield Horizontal(Label(" Usage:",), + yield Horizontal(Label(" Usage:",), Label("", id="pwr_avg_val", classes="statvalue")) - # padding to split groups - yield Horizontal(Label(""), Label("", classes="statvalue")) - yield Horizontal(Label("[underline]Limits"), - Label("", classes="statvalue")) - yield Horizontal(Label(" Configured:",), + yield Horizontal(Label(" Set Limit:",), Label("", id="pwr_lim_val", classes="statvalue")) - yield Horizontal(Label(" Default:",), + yield Horizontal(Label(" Default Limit:",), Label("", id="pwr_def_val", classes="statvalue")) - yield Horizontal(Label(" Board capability:",), + yield Horizontal(Label(" Capability:",), Label("", id="pwr_cap_val", classes="statvalue")) + yield Horizontal() + yield Horizontal() + yield Horizontal() + yield Horizontal() def on_mount(self) -> None: """Event handler called when widget is added to the app.""" @@ -274,7 +312,7 @@ class PowerDisplay(Static): """Method to update GPU power values to current measurements. Run by a timer created 'on_mount'""" - self.watts = get_power_stats(CARD) + self.watts = get_power_stats(self.card) def watch_watts(self, watts: dict) -> None: """Called when the 'watts' reactive attribute (var) changes. @@ -290,10 +328,10 @@ class PowerDisplay(Static): Static).update(f"{watts['capability']}W") -def tui() -> None: +def start() -> None: '''Spawns the textual UI only during CLI invocation / after argparse''' if len(AMDGPU_CARDS) > 0: - app = GPUStats() + app = AMDGPUStats(watch_css=True) app.run() else: sys.exit('Could not find an AMD GPU, exiting.')