Archived
1
1
Fork 0

v0.1.11: add SVG 'screenshots', support many cards in TUI

v0.1.11: add SVG 'screenshots', support many cards in TUI
This commit is contained in:
Josh Lay 2023-04-29 21:19:55 -05:00 committed by GitHub
commit 7af94e21cb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 155 additions and 102 deletions

View file

@ -1,6 +1,6 @@
# amdgpu_stats # 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") ![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 - `help('amdgpu_stats.utils')` in your interpreter
- [The module source](https://github.com/joshlay/amdgpu_stats/blob/master/src/amdgpu_stats/utils.py) - [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 ## Requirements
Only `Linux` is supported. Information is _completely_ sourced from interfaces in `sysfs`. Only `Linux` is supported. Information is _completely_ sourced from interfaces in `sysfs`.

View file

@ -1,7 +1,7 @@
[tool.poetry] [tool.poetry]
name = "amdgpu-stats" name = "amdgpu-stats"
version = "0.1.10" version = "0.1.11"
description = "A simple module/TUI for AMD GPU statistics" description = "A module/TUI for AMD GPU statistics"
authors = ["Josh Lay <pypi@jlay.io>"] authors = ["Josh Lay <pypi@jlay.io>"]
repository = "https://github.com/joshlay/amdgpu_stats" repository = "https://github.com/joshlay/amdgpu_stats"
license = "MIT" license = "MIT"
@ -14,7 +14,7 @@ textual = ">=0.10"
humanfriendly = ">=10.0" humanfriendly = ">=10.0"
[tool.poetry.scripts] [tool.poetry.scripts]
amdgpu-stats = "amdgpu_stats.interface:tui" amdgpu-stats = "amdgpu_stats.tui:start"
[build-system] [build-system]
requires = ["poetry-core"] requires = ["poetry-core"]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View file

@ -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()

View file

@ -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;
}

View file

@ -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()

View file

@ -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;
}

View file

@ -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: Classes:
- GPUStats: the object for the _Application_, instantiated at runtime - GPUStats: the object for the _Application_, instantiated at runtime
@ -12,9 +14,13 @@ Classes:
- LogScreen: Second screen with the logging widget, header, and footer - LogScreen: Second screen with the logging widget, header, and footer
Functions: 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 import sys
from datetime import datetime
from os import path
from textual.binding import Binding from textual.binding import Binding
from textual.app import App, ComposeResult 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 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 # rich markup reference:
if len(AMDGPU_CARDS) > 0: # https://rich.readthedocs.io/en/stable/markup.html
# default to showing stats for the first detected card
CARD = next(iter(AMDGPU_CARDS))
hwmon_dir = AMDGPU_CARDS[CARD]
class LogScreen(Screen): class LogScreen(Screen):
@ -44,7 +47,7 @@ class LogScreen(Screen):
On first display in this case.""" On first display in this case."""
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Header() yield Header(show_clock=True)
yield Container(self.text_log) yield Container(self.text_log)
yield Footer() yield Footer()
@ -56,36 +59,57 @@ class LogScreen(Screen):
class GPUStatsWidget(Static): class GPUStatsWidget(Static):
"""The main stats widget.""" """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: def compose(self) -> ComposeResult:
"""Create child widgets.""" """Create child widgets."""
yield ClockDisplay(classes="box") yield ClockDisplay(classes="box", card=self.card, hwmon_dir=self.hwmon_dir)
yield PowerDisplay(classes="box") yield PowerDisplay(classes="box", card=self.card, hwmon_dir=self.hwmon_dir)
yield MiscDisplay(classes="box") 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.""" """Textual-based tool to show AMDGPU statistics."""
# apply stylesheet # apply stylesheet
CSS_PATH = 'amdgpu_stats.css' CSS_PATH = 'style.css'
# initialize log screen # initialize log screen
SCREENS = {"logs": LogScreen()} SCREENS = {"logs": LogScreen()}
# title the app after the card
# TITLE = 'GPUStats - ' + CARD
# setup keybinds # setup keybinds
# Binding("l", "push_screen('logs')", "Toggle logs", priority=True), # Binding("l", "push_screen('logs')", "Toggle logs", priority=True),
BINDINGS = [ BINDINGS = [
Binding("c", "toggle_dark", "Toggle colors", priority=True), Binding("c", "custom_dark", "Colors"),
Binding("l", "toggle_log", "Toggle logs", priority=True), Binding("l", "toggle_log", "Logs"),
Binding("q", "quit_app", "Quit", priority=True) Binding("s", "screenshot_wrapper", "Screenshot"),
Binding("q", "quit", "Quit")
] ]
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
"""Create child widgets for the app.""" """Create child widgets for the app."""
yield Header() yield Header(show_clock=True)
yield Container(GPUStatsWidget()) 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("[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 # nice-to-have: account for not storing these in dicts, but resolved in funcs
# for metric, source in SRC_FILES.items(): # for metric, source in SRC_FILES.items():
# self.update_log(f'[bold] {metric}:[/] {source}') # self.update_log(f'[bold] {metric}:[/] {source}')
@ -93,16 +117,23 @@ class GPUStats(App):
# self.update_log(f'[bold] {metric} temperature:[/] {source}') # self.update_log(f'[bold] {metric} temperature:[/] {source}')
yield Footer() yield Footer()
def action_toggle_dark(self) -> None: async def action_custom_dark(self) -> None:
"""An action to toggle dark mode.""" """An action to toggle dark mode.
Wraps 'action_toggle_dark' with logging and a refresh"""
self.dark = not self.dark self.dark = not self.dark
self.update_log(f"[bold]Dark side: [italic]{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: def action_screenshot_wrapper(self, screen_dir: str = '/tmp') -> None:
"""An action to quit the program""" """Action that fires when the user presses 's' for a screenshot"""
message = "Exiting on user request" # construct the screenshot elements + path
self.update_log(f"[bold]{message}") timestamp = datetime.now().isoformat().replace(":", "_")
self.exit(message) 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: def action_toggle_log(self) -> None:
"""Toggle between the main screen and the LogScreen.""" """Toggle between the main screen and the LogScreen."""
@ -125,7 +156,6 @@ class MiscDisplay(Static):
fan_rpm_target = reactive(0) fan_rpm_target = reactive(0)
# do some dancing to craft the UI; initialize the reactive obj with data # do some dancing to craft the UI; initialize the reactive obj with data
# to get proper labels # to get proper labels
initial_stats = get_temp_stats(CARD)
# dynamic object for temperature updates # dynamic object for temperature updates
temp_stats = reactive({}) temp_stats = reactive({})
# default to 'not composed', once labels are made - become true # default to 'not composed', once labels are made - become true
@ -133,10 +163,12 @@ class MiscDisplay(Static):
# ... and making labels for them # ... and making labels for them
composed = False composed = False
def __init__(self, *args, **kwargs): def __init__(self, card: str, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.timer_misc = None 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: def compose(self) -> ComposeResult:
yield Horizontal(Label("[underline]Temperatures"), yield Horizontal(Label("[underline]Temperatures"),
@ -144,14 +176,15 @@ class MiscDisplay(Static):
for temp_node in self.initial_stats: for temp_node in self.initial_stats:
# capitalize the first letter for display # capitalize the first letter for display
caption = temp_node[0].upper() + temp_node[1:] caption = temp_node[0].upper() + temp_node[1:]
yield Horizontal(Label(f' {caption}:',), yield Horizontal(Label(f' {caption}:',),
Label("", id="temp_" + temp_node, Label("", id="temp_" + temp_node, classes="statvalue"))
classes="statvalue")) # padding to split groups
yield Horizontal()
yield Horizontal(Label("[underline]Fan RPM"), yield Horizontal(Label("[underline]Fan RPM"),
Label("", classes="statvalue")) Label("", classes="statvalue"))
yield Horizontal(Label(" Current:",), yield Horizontal(Label(" Current:",),
Label("", id="fan_rpm", classes="statvalue")) Label("", id="fan_rpm", classes="statvalue"))
yield Horizontal(Label(" Target:",), yield Horizontal(Label(" Target:",),
Label("", id="fan_rpm_target", classes="statvalue")) Label("", id="fan_rpm_target", classes="statvalue"))
self.composed = True self.composed = True
@ -163,9 +196,9 @@ class MiscDisplay(Static):
"""Method to update the temp/fan values to current measurements. """Method to update the temp/fan values to current measurements.
Run by a timer created 'on_mount'""" Run by a timer created 'on_mount'"""
self.fan_rpm = get_fan_rpm(CARD) self.fan_rpm = get_fan_rpm(self.card)
self.fan_rpm_target = get_fan_target(CARD) self.fan_rpm_target = get_fan_target(self.card)
self.temp_stats = get_temp_stats(CARD) self.temp_stats = get_temp_stats(self.card)
def watch_fan_rpm(self, fan_rpm: int) -> None: def watch_fan_rpm(self, fan_rpm: int) -> None:
"""Called when the 'fan_rpm' reactive attr changes. """Called when the 'fan_rpm' reactive attr changes.
@ -195,25 +228,28 @@ class ClockDisplay(Static):
"""A widget to display GPU power stats.""" """A widget to display GPU power stats."""
core_vals = reactive({"sclk": 0, "mclk": 0, "voltage": 0, "util_pct": 0}) 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) super().__init__(*args, **kwargs)
self.timer_clocks = None self.timer_clocks = None
self.card = card
self.hwmon_dir = hwmon_dir
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Horizontal(Label("[underline]Clocks"), yield Horizontal(Label("[underline]Performance"),
Label("", classes="statvalue")) Label("", classes="statvalue"))
yield Horizontal(Label(" GPU core:",), yield Horizontal(Label(" Core clock:",),
Label("", id="clk_core_val", classes="statvalue")) Label("", id="clk_core_val", classes="statvalue"))
yield Horizontal(Label(" Memory:"), yield Horizontal(Label(" Memory clock:"),
Label("", id="clk_memory_val", classes="statvalue")) Label("", id="clk_memory_val", classes="statvalue"))
# padding to split groups yield Horizontal(Label(" Utilization:",),
yield Horizontal(Label(""), Label("", classes="statvalue"))
yield Horizontal(Label("[underline]Core"),
Label("", classes="statvalue"))
yield Horizontal(Label(" Utilization:",),
Label("", id="util_pct", classes="statvalue")) Label("", id="util_pct", classes="statvalue"))
yield Horizontal(Label(" Voltage:",), yield Horizontal(Label(" Voltage:",),
Label("", id="clk_voltage_val", classes="statvalue")) 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: def on_mount(self) -> None:
"""Event handler called when widget is added to the app.""" """Event handler called when widget is added to the app."""
@ -222,7 +258,7 @@ class ClockDisplay(Static):
def update_core_vals(self) -> None: def update_core_vals(self) -> None:
"""Method to update GPU clock values to the current measurements. """Method to update GPU clock values to the current measurements.
Run by a timer created 'on_mount'""" 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: def watch_core_vals(self, core_vals: dict) -> None:
"""Called when the clocks attribute changes """Called when the clocks attribute changes
@ -246,25 +282,27 @@ class PowerDisplay(Static):
"capability": 0, "capability": 0,
"default": 0}) "default": 0})
def __init__(self, *args, **kwargs): def __init__(self, card: str, hwmon_dir: str, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.timer_watts = None self.timer_watts = None
self.card = card
self.hwmon_dir = hwmon_dir
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Horizontal(Label("[underline]Power"), yield Horizontal(Label("[underline]Power"),
Label("", classes="statvalue")) Label("", classes="statvalue"))
yield Horizontal(Label(" Usage:",), yield Horizontal(Label(" Usage:",),
Label("", id="pwr_avg_val", classes="statvalue")) Label("", id="pwr_avg_val", classes="statvalue"))
# padding to split groups yield Horizontal(Label(" Set Limit:",),
yield Horizontal(Label(""), Label("", classes="statvalue"))
yield Horizontal(Label("[underline]Limits"),
Label("", classes="statvalue"))
yield Horizontal(Label(" Configured:",),
Label("", id="pwr_lim_val", classes="statvalue")) Label("", id="pwr_lim_val", classes="statvalue"))
yield Horizontal(Label(" Default:",), yield Horizontal(Label(" Default Limit:",),
Label("", id="pwr_def_val", classes="statvalue")) Label("", id="pwr_def_val", classes="statvalue"))
yield Horizontal(Label(" Board capability:",), yield Horizontal(Label(" Capability:",),
Label("", id="pwr_cap_val", classes="statvalue")) Label("", id="pwr_cap_val", classes="statvalue"))
yield Horizontal()
yield Horizontal()
yield Horizontal()
yield Horizontal()
def on_mount(self) -> None: def on_mount(self) -> None:
"""Event handler called when widget is added to the app.""" """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. """Method to update GPU power values to current measurements.
Run by a timer created 'on_mount'""" 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: def watch_watts(self, watts: dict) -> None:
"""Called when the 'watts' reactive attribute (var) changes. """Called when the 'watts' reactive attribute (var) changes.
@ -290,10 +328,10 @@ class PowerDisplay(Static):
Static).update(f"{watts['capability']}W") Static).update(f"{watts['capability']}W")
def tui() -> None: def start() -> None:
'''Spawns the textual UI only during CLI invocation / after argparse''' '''Spawns the textual UI only during CLI invocation / after argparse'''
if len(AMDGPU_CARDS) > 0: if len(AMDGPU_CARDS) > 0:
app = GPUStats() app = AMDGPUStats(watch_css=True)
app.run() app.run()
else: else:
sys.exit('Could not find an AMD GPU, exiting.') sys.exit('Could not find an AMD GPU, exiting.')