From 9998ec8f38bf5aa000191db237203416222feeee Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 22:27:48 +0000 Subject: [PATCH 1/4] Harden zenmonitor, add non-blocking sampling, headless CLI, and rolling averages This is a consolidated set of improvements to zenmonitor, developed and verified incrementally. Runtime sensor values require an AMD Zen host to confirm; everything was built clean (-O2 -Wall) and the pure logic (averaging, filters) was unit-checked against brute-force references. Correctness & safety: - cpu_model(): fix a 1-byte stack buffer overflow (model[48] -> model[49]). - zenpower_init(): check the hwmon "name" read and reset the buffer after free, avoiding a NULL deref / use-after-free. - get_core_fid(): guard against divide-by-zero when the FDID field is 0. - Replace unchecked malloc() with g_malloc(); sprintf -> snprintf; drop dead code and a typo in get_core_count(); fix a signed/unsigned comparison and a shadowed counter in sysfs.c; free the leaked cpu_model() result in the GUI; convert K&R () prototypes to explicit/(void) forms. CPU support: - Accept family 0x19 (Zen 3 / Zen 4) in addition to 0x17, matching the established zenmonitor3 fork (RAPL/energy/FID logic unchanged). UI responsiveness: - Remove the blocking usleep(100ms) from msr_update(); RAPL power is now measured over the real elapsed time between refreshes (monotonic clock), so the GTK main loop is no longer frozen ~1/3 of each cycle. Refresh interval lowered to 200ms now that ticks are cheap. Headless CLI (zenmonitor-cli): - Split the shared core into zenmonitor-lib.c; add a command-line build with stdout output, --delay, --coreid, --refresh-in-place (ncurses), --output-once, and --file (CSV export on exit). makefile gains build-cli/install-cli/all; also drops DESTDIR from the .desktop Exec path. - Signal handler only sets a flag (flush/teardown happen in the main loop); fopen() is checked; the CSV writer iterates by length; the data store frees its per-series arrays. Rolling averages: - Optional GUI columns via --average (e.g. 30s,1m,5m) with a central ring-buffer + running-sum engine (exact windowed mean, O(windows)/tick). --average-only restricts which sensors are averaged. Omitting either keeps the original behaviour. - CLI --daemon mode keeps history resident and atomically rewrites a snapshot file each interval (default under $XDG_RUNTIME_DIR), so short-lived readers (e.g. an xfce4-genmon panel) can show a trailing average. --sensors limits which sensors are handled and skips a backend's per-tick reads when nothing matches. Example genmon script and systemd user unit under data/. The averaging math and the substring filter live once in zenmonitor-lib.c and are shared by the GUI and CLI. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BhfyG1chPR37duryk96VTa --- .gitignore | 3 + README.md | 49 +++++ data/zenmonitor-cli.service | 18 ++ data/zenmonitor-genmon.sh | 49 +++++ makefile | 40 ++++- src/gui.c | 162 ++++++++++++----- src/include/gui.h | 4 +- src/include/msr.h | 8 +- src/include/zenmonitor.h | 63 ++++++- src/include/zenpower.h | 8 +- src/ss/msr.c | 109 +++++++---- src/ss/os.c | 8 +- src/ss/zenpower.c | 12 +- src/sysfs.c | 10 +- src/zenmonitor-cli.c | 347 ++++++++++++++++++++++++++++++++++++ src/zenmonitor-lib.c | 332 ++++++++++++++++++++++++++++++++++ src/zenmonitor.c | 95 +--------- 17 files changed, 1110 insertions(+), 207 deletions(-) create mode 100644 .gitignore create mode 100644 data/zenmonitor-cli.service create mode 100755 data/zenmonitor-genmon.sh create mode 100644 src/zenmonitor-cli.c create mode 100644 src/zenmonitor-lib.c diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..32a5e3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/zenmonitor +/zenmonitor-cli +*.o diff --git a/README.md b/README.md index 23ecc99..f839974 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,55 @@ Alternatively, you can set capabilities to zenmonitor executable: `sudo setcap c ``--coreid`` - Display core_id instead of core index +``--average WINDOWS`` - Show additional rolling-average columns for the given comma-separated time windows, e.g. ``--average 30s,1m,5m`` (suffixes: ``s`` seconds, ``m`` minutes, ``h`` hours; a bare number is seconds). Omit to show no average columns. + +``--average-only SUBSTRINGS`` - Only average sensors whose label contains one of these comma-separated substrings (case-insensitive), e.g. ``--average-only power,temp``. Non-matching rows still show Value/Min/Max but leave the average cells blank. Omit to average every sensor. + +## Command line interface (zenmonitor-cli) +A headless build is available for terminals and panels: +``` +make build-cli +sudo make install-cli +``` +It reuses the same sensor backends and supports ``--delay SECONDS`` (poll +interval), ``--coreid``, ``--refresh-in-place`` (redraw in place), ``--output-once``, +and ``--file FILE`` (dump all collected readings to CSV on exit). + +``--sensors SUBSTRINGS`` limits output to sensors whose label contains one of the +given comma-separated substrings (case-insensitive), e.g. +``--sensors "temperature,package power"``. Besides trimming the output and the +per-sensor average buffers, if a whole backend has no matching sensors its +per-tick read is skipped entirely (handy to avoid the MSR reads when you only +want temperatures). + +### Rolling averages in a panel (daemon mode) +A rolling average needs a process that has been sampling for the whole window, +so short-lived panel commands can't compute one on their own. Run zenmonitor-cli +as a small resident daemon instead: +``` +zenmonitor-cli --daemon --delay 1 --average 1m,5m +``` +It samples every ``--delay`` seconds and rewrites a snapshot file (default +``$XDG_RUNTIME_DIR/zenmonitor.snapshot``, override with ``--snapshot FILE``) +containing each sensor's current value and the configured rolling averages. +The window→sample conversion follows ``--delay``, so ``5m`` is five minutes at +any poll rate. + +``data/zenmonitor-cli.service`` is an example systemd *user* unit that keeps the +daemon running. ``data/zenmonitor-genmon.sh`` reads the snapshot for the +[xfce4-genmon-plugin](https://docs.xfce.org/panel-plugins/xfce4-genmon-plugin); +point a genmon item at, for example: +``` +zenmonitor-genmon.sh "CPU Temperature (tCtl)" "Avg 1m" +``` + +Note: temperature/SVI2 sensors (via the zenpower driver) work as a normal user, +but RAPL package/core power needs MSR privileges. To include power in the daemon +snapshot, grant capabilities to the binary: +``` +sudo setcap cap_sys_rawio,cap_dac_read_search+ep /usr/local/bin/zenmonitor-cli +``` + ## Installing By default, Zenmonitor will be installed to /usr/local. ``` diff --git a/data/zenmonitor-cli.service b/data/zenmonitor-cli.service new file mode 100644 index 0000000..f696016 --- /dev/null +++ b/data/zenmonitor-cli.service @@ -0,0 +1,18 @@ +# Example systemd *user* service for the zenmonitor-cli snapshot daemon. +# Install: cp data/zenmonitor-cli.service ~/.config/systemd/user/ +# systemctl --user enable --now zenmonitor-cli.service +# +# Note: SVI2/temperature sensors (zenpower) work as a normal user, but RAPL +# package/core power comes from the MSR device and needs privileges. Grant them +# to the binary if you want power in the snapshot: +# sudo setcap cap_sys_rawio,cap_dac_read_search+ep /usr/local/bin/zenmonitor-cli + +[Unit] +Description=Zenmonitor CLI snapshot daemon (rolling averages for panels) + +[Service] +ExecStart=/usr/local/bin/zenmonitor-cli --daemon --delay 1 --average 1m,5m +Restart=on-failure + +[Install] +WantedBy=default.target diff --git a/data/zenmonitor-genmon.sh b/data/zenmonitor-genmon.sh new file mode 100755 index 0000000..6226a83 --- /dev/null +++ b/data/zenmonitor-genmon.sh @@ -0,0 +1,49 @@ +#!/bin/sh +# xfce4-genmon-plugin consumer for the zenmonitor-cli daemon snapshot. +# +# Usage in a genmon panel item (Command field): +# zenmonitor-genmon.sh "CPU Temperature (tCtl)" "Avg 1m" +# +# Arg 1: exact sensor label as written in the snapshot (default: first sensor) +# Arg 2: column to display - "value" or one of the configured average +# windows, e.g. "Avg 1m" (default: value) +# +# The snapshot is produced by: zenmonitor-cli --daemon --average 1m,5m +# Path resolution: $ZENMONITOR_SNAPSHOT, else $XDG_RUNTIME_DIR/zenmonitor.snapshot, +# else /tmp/zenmonitor.snapshot. + +snapshot="${ZENMONITOR_SNAPSHOT:-${XDG_RUNTIME_DIR:-/tmp}/zenmonitor.snapshot}" +sensor="$1" +column="${2:-value}" + +if [ ! -r "$snapshot" ]; then + echo "n/a" + echo "zenmonitor daemon not running ($snapshot)" + exit 0 +fi + +awk -F'\t' -v sensor="$sensor" -v col="$column" ' + NR == 2 { + sub(/^# /, "", $0) + n = split($0, h, "\t") + for (i = 1; i <= n; i++) + if (h[i] == col) ci = i + if (sensor == "") skip_sensor = 1 + next + } + NR > 2 && (skip_sensor || $1 == sensor) { + if (ci == "") { print "?col"; exit } + v = $ci + if (v == "") { print "--"; exit } + printf "%.1f\n", v + printf "%s — %s: %.2f\n", $1, col, v + found = 1 + exit + } + END { + if (!found) { + print "n/a" + print "sensor not found: " sensor "" + } + } +' "$snapshot" diff --git a/makefile b/makefile index dabe5d2..e57febc 100755 --- a/makefile +++ b/makefile @@ -1,25 +1,52 @@ +CC := cc + ifeq ($(PREFIX),) PREFIX := /usr/local endif +BUILD_FILES_COMMON := \ + src/ss/*.c \ + src/sysfs.c \ + src/zenmonitor-lib.c + +BUILD_FILES_GUI := \ + $(BUILD_FILES_COMMON) \ + src/gui.c \ + src/zenmonitor.c + +BUILD_FILES_CLI := \ + $(BUILD_FILES_COMMON) \ + src/zenmonitor-cli.c + +.PHONY: build build-cli all install install-cli install-polkit uninstall uninstall-cli clean + build: - cc -Isrc/include `pkg-config --cflags gtk+-3.0` src/*.c src/ss/*.c -o zenmonitor `pkg-config --libs gtk+-3.0` -lm -no-pie -Wall + $(CC) -Isrc/include `pkg-config --cflags gtk+-3.0` $(BUILD_FILES_GUI) -o zenmonitor `pkg-config --libs gtk+-3.0` -lm -no-pie -O2 -Wall $(CFLAGS) + +build-cli: + $(CC) -Isrc/include `pkg-config --cflags glib-2.0` $(BUILD_FILES_CLI) -o zenmonitor-cli `pkg-config --libs glib-2.0` -lm -lncurses -no-pie -O2 -Wall $(CFLAGS) + +all: build build-cli install: mkdir -p $(DESTDIR)$(PREFIX)/bin install -m 755 zenmonitor $(DESTDIR)$(PREFIX)/bin mkdir -p $(DESTDIR)$(PREFIX)/share/applications - sed -e "s|@APP_EXEC@|${DESTDIR}${PREFIX}/bin/zenmonitor|" \ + sed -e "s|@APP_EXEC@|${PREFIX}/bin/zenmonitor|" \ data/zenmonitor.desktop.in > \ $(DESTDIR)$(PREFIX)/share/applications/zenmonitor.desktop +install-cli: + mkdir -p $(DESTDIR)$(PREFIX)/bin + install -m 755 zenmonitor-cli $(DESTDIR)$(PREFIX)/bin + install-polkit: - sed -e "s|@APP_EXEC@|${DESTDIR}${PREFIX}/bin/zenmonitor|" \ + sed -e "s|@APP_EXEC@|${PREFIX}/bin/zenmonitor|" \ data/zenmonitor-root.desktop.in > \ $(DESTDIR)$(PREFIX)/share/applications/zenmonitor-root.desktop - sed -e "s|@APP_EXEC@|${DESTDIR}${PREFIX}/bin/zenmonitor|" \ + sed -e "s|@APP_EXEC@|${PREFIX}/bin/zenmonitor|" \ data/org.pkexec.zenmonitor.policy.in > \ $(DESTDIR)/usr/share/polkit-1/actions/org.pkexec.zenmonitor.policy @@ -29,5 +56,8 @@ uninstall: rm -f $(DESTDIR)$(PREFIX)/share/applications/zenmonitor-root.desktop rm -f $(DESTDIR)/usr/share/polkit-1/actions/org.pkexec.zenmonitor.policy +uninstall-cli: + rm -f $(DESTDIR)$(PREFIX)/bin/zenmonitor-cli + clean: - rm -f zenmonitor + rm -f zenmonitor zenmonitor-cli *.o diff --git a/src/gui.c b/src/gui.c index 11ef4f8..6b45b0a 100644 --- a/src/gui.c +++ b/src/gui.c @@ -1,7 +1,11 @@ #include #include -#include "gui.h" #include "zenmonitor.h" +#include "gui.h" + +// Sensor refresh cadence. Used both for the GTK timer and to translate the +// configured average windows (in seconds) into a number of samples. +#define REFRESH_INTERVAL_MS 200 GtkWidget *window; @@ -10,14 +14,44 @@ static guint timeout = 0; static SensorSource *sensor_sources; static const guint defaultHeight = 350; -enum { - COLUMN_NAME, - COLUMN_HINT, - COLUMN_VALUE, - COLUMN_MIN, - COLUMN_MAX, - NUM_COLUMNS -}; +// Fixed columns; any configured rolling-average columns follow at +// COLUMN_AVG_BASE .. COLUMN_AVG_BASE + avg->count - 1. +#define COLUMN_NAME 0 +#define COLUMN_HINT 1 +#define COLUMN_VALUE 2 +#define COLUMN_MIN 3 +#define COLUMN_MAX 4 +#define COLUMN_AVG_BASE 5 + +// Rolling-average configuration (count == 0 by default -> no average columns). +static AvgWindows *avg = NULL; +static AvgSeries *series = NULL; // one per tree row +static gboolean *row_avg = NULL; // whether each row is averaged (--average-only) +static gchar **avg_filter = NULL; // NULL => average every sensor +static guint n_rows = 0; + +static guint avg_count(void) { + return avg ? avg->count : 0; +} + +// Parse a comma-separated list of window durations (e.g. "30s,1m,5m"). +// Passing NULL or an empty string leaves averaging disabled. +void gui_set_averages(const gchar *spec) { + if (avg) + avg_windows_free(avg); + avg = avg_windows_parse(spec, REFRESH_INTERVAL_MS); +} + +// Restrict which sensors get averaged to those whose label contains one of the +// given comma-separated substrings. NULL/empty averages every sensor. +void gui_set_average_filter(const gchar *spec) { + str_filter_free(avg_filter); + avg_filter = str_filter_parse(spec); +} + +static guint window_width(void) { + return 500 + avg_count() * 100; +} static void init_sensors() { GtkTreeIter iter; @@ -25,7 +59,7 @@ static void init_sensors() { GtkListStore *store; SensorSource *source; const SensorInit *data; - guint i = 0; + guint i = 0, k; store = GTK_LIST_STORE(model); for (source = sensor_sources; source->drv; source++) { @@ -45,17 +79,49 @@ static void init_sensors() { COLUMN_MIN, " --- ", COLUMN_MAX, " --- ", -1); + for (k = 0; k < avg_count(); k++) + gtk_list_store_set(store, &iter, COLUMN_AVG_BASE + k, " --- ", -1); sensor = sensor->next; i++; } } } } + + // Allocate per-row history once we know how many rows exist. A second pass + // (same iteration order) decides, per row, whether it is averaged, and only + // those rows get a ring buffer allocated. + n_rows = i; + if (avg_count() > 0 && n_rows > 0) { + guint r = 0; + series = g_new0(AvgSeries, n_rows); + row_avg = g_new0(gboolean, n_rows); + + for (source = sensor_sources; source->drv; source++) { + if (!source->enabled) + continue; + for (sensor = source->sensors; sensor; sensor = sensor->next) { + data = (SensorInit *)sensor->data; + row_avg[r] = str_filter_match(avg_filter, data->label); + if (row_avg[r]) + avg_series_init(&series[r], avg); + r++; + } + } + } } static GtkTreeModel* create_model (void) { GtkListStore *store; - store = gtk_list_store_new (NUM_COLUMNS, G_TYPE_STRING, G_TYPE_STRING, G_TYPE_STRING, G_TYPE_STRING, G_TYPE_STRING); + guint n_columns = COLUMN_AVG_BASE + avg_count(); + GType *types = g_new(GType, n_columns); + guint c; + + for (c = 0; c < n_columns; c++) + types[c] = G_TYPE_STRING; + + store = gtk_list_store_newv(n_columns, types); + g_free(types); return GTK_TREE_MODEL (store); } @@ -69,11 +135,28 @@ static void set_list_column_value(float num, const gchar *printf_format, GtkTree g_free(value); } +// Push a fresh reading into a row's history and refresh its average columns. +static void update_averages(guint row, float value, const gchar *printf_format, GtkTreeIter *iter) { + guint k; + + if (avg_count() == 0 || row >= n_rows || !row_avg[row]) + return; + + avg_series_push(&series[row], avg, value); + if (!series[row].valid) + return; + + for (k = 0; k < avg->count; k++) + set_list_column_value((float)series[row].avg[k], printf_format, iter, + COLUMN_AVG_BASE + k); +} + static gboolean update_data (gpointer data) { GtkTreeIter iter; GSList *node; SensorSource *source; const SensorInit *sensorData; + guint row = 0; if (model == NULL) return G_SOURCE_REMOVE; @@ -84,7 +167,7 @@ static gboolean update_data (gpointer data) { for (source = sensor_sources; source->drv; source++) { if (!source->enabled) continue; - + source->func_update(); if (source->sensors){ node = source->sensors; @@ -94,7 +177,9 @@ static gboolean update_data (gpointer data) { set_list_column_value(*(sensorData->value), sensorData->printf_format, &iter, COLUMN_VALUE); set_list_column_value(*(sensorData->min), sensorData->printf_format, &iter, COLUMN_MIN); set_list_column_value(*(sensorData->max), sensorData->printf_format, &iter, COLUMN_MAX); + update_averages(row, *(sensorData->value), sensorData->printf_format, &iter); + row++; node = node->next; if (!gtk_tree_model_iter_next(model, &iter)) break; @@ -104,41 +189,28 @@ static gboolean update_data (gpointer data) { return G_SOURCE_CONTINUE; } -static void add_columns (GtkTreeView *treeview) { +static void append_text_column(GtkTreeView *treeview, const gchar *title, gint column) { GtkCellRenderer *renderer; - GtkTreeViewColumn *column; + GtkTreeViewColumn *col; - // NAME renderer = gtk_cell_renderer_text_new (); - column = gtk_tree_view_column_new_with_attributes ("Sensor", renderer, - "text", COLUMN_NAME, - NULL); + col = gtk_tree_view_column_new_with_attributes (title, renderer, + "text", column, + NULL); g_object_set(renderer, "family", "monotype", NULL); - gtk_tree_view_append_column (treeview, column); + gtk_tree_view_append_column (treeview, col); +} - //VALUE - renderer = gtk_cell_renderer_text_new (); - column = gtk_tree_view_column_new_with_attributes ("Value", renderer, - "text", COLUMN_VALUE, - NULL); - g_object_set(renderer, "family", "monotype", NULL); - gtk_tree_view_append_column (treeview, column); +static void add_columns (GtkTreeView *treeview) { + guint k; - //MIN - renderer = gtk_cell_renderer_text_new (); - column = gtk_tree_view_column_new_with_attributes ("Min", renderer, - "text", COLUMN_MIN, - NULL); - g_object_set(renderer, "family", "monotype", NULL); - gtk_tree_view_append_column (treeview, column); + append_text_column(treeview, "Sensor", COLUMN_NAME); + append_text_column(treeview, "Value", COLUMN_VALUE); + append_text_column(treeview, "Min", COLUMN_MIN); + append_text_column(treeview, "Max", COLUMN_MAX); - //MAX - renderer = gtk_cell_renderer_text_new (); - column = gtk_tree_view_column_new_with_attributes ("Max", renderer, - "text", COLUMN_MAX, - NULL); - g_object_set(renderer, "family", "monotype", NULL); - gtk_tree_view_append_column (treeview, column); + for (k = 0; k < avg_count(); k++) + append_text_column(treeview, avg->titles[k], COLUMN_AVG_BASE + k); } static void about_btn_clicked(GtkButton *button, gpointer user_data) { @@ -201,7 +273,7 @@ static void resize_to_treeview(GtkWindow* window, GtkTreeView* treeview) { gtk_tree_view_get_visible_rect(treeview, &r); uiHeight = defaultHeight - r.height; - gtk_window_resize(window, 500, uiHeight + (vSeparator + cellHeight) * rows); + gtk_window_resize(window, window_width(), uiHeight + (vSeparator + cellHeight) * rows); } int start_gui (SensorSource *ss) { @@ -216,14 +288,16 @@ int start_gui (SensorSource *ss) { window = gtk_window_new(GTK_WINDOW_TOPLEVEL); gtk_window_set_position(GTK_WINDOW(window), GTK_WIN_POS_CENTER); - gtk_window_set_default_size(GTK_WINDOW(window), 500, defaultHeight); + gtk_window_set_default_size(GTK_WINDOW(window), window_width(), defaultHeight); + gchar *cpu_model_str = cpu_model(); header = gtk_header_bar_new(); gtk_header_bar_set_show_close_button(GTK_HEADER_BAR (header), TRUE); gtk_header_bar_set_title(GTK_HEADER_BAR (header), "Zen monitor"); gtk_header_bar_set_has_subtitle(GTK_HEADER_BAR (header), TRUE); - gtk_header_bar_set_subtitle(GTK_HEADER_BAR (header), cpu_model()); + gtk_header_bar_set_subtitle(GTK_HEADER_BAR (header), cpu_model_str); gtk_window_set_titlebar (GTK_WINDOW (window), header); + g_free(cpu_model_str); box = gtk_box_new (GTK_ORIENTATION_HORIZONTAL, 0); gtk_style_context_add_class (gtk_widget_get_style_context (box), "linked"); @@ -270,7 +344,7 @@ int start_gui (SensorSource *ss) { init_sensors(); resize_to_treeview(GTK_WINDOW(window), GTK_TREE_VIEW(treeview)); - timeout = g_timeout_add(300, update_data, NULL); + timeout = g_timeout_add(REFRESH_INTERVAL_MS, update_data, NULL); } else{ dialog = gtk_message_dialog_new(GTK_WINDOW (window), diff --git a/src/include/gui.h b/src/include/gui.h index 0bc2501..01f1db6 100644 --- a/src/include/gui.h +++ b/src/include/gui.h @@ -1 +1,3 @@ -int start_gui(); +int start_gui(SensorSource *ss); +void gui_set_averages(const gchar *spec); +void gui_set_average_filter(const gchar *spec); diff --git a/src/include/msr.h b/src/include/msr.h index 664a5ee..dc0018c 100644 --- a/src/include/msr.h +++ b/src/include/msr.h @@ -1,4 +1,4 @@ -gboolean msr_init(); -void msr_update(); -void msr_clear_minmax(); -GSList* msr_get_sensors(); +gboolean msr_init(void); +void msr_update(void); +void msr_clear_minmax(void); +GSList* msr_get_sensors(void); diff --git a/src/include/zenmonitor.h b/src/include/zenmonitor.h index 43a6e3f..90fbd0e 100644 --- a/src/include/zenmonitor.h +++ b/src/include/zenmonitor.h @@ -1,3 +1,9 @@ +#ifndef __ZENMONITOR_ZENMONITOR_H__ +#define __ZENMONITOR_ZENMONITOR_H__ + +#include +#include + #define ERROR_VALUE -999.0 #define VERSION "1.4.2" @@ -14,17 +20,60 @@ SensorInit; typedef struct { const gchar *drv; - gboolean (*func_init)(); - GSList* (*func_get_sensors)(); - void (*func_update)(); - void (*func_clear_minmax)(); + gboolean (*func_init)(void); + GSList* (*func_get_sensors)(void); + void (*func_update)(void); + void (*func_clear_minmax)(void); gboolean enabled; GSList *sensors; } SensorSource; +typedef struct { + GPtrArray *labels; + GPtrArray *data; + GArray *time; +} SensorDataStore; + +// Rolling-average configuration, shared by the GUI columns and the CLI daemon. +typedef struct { + gchar **titles; // count entries: column/label heading, e.g. "Avg 1m" + guint *samples; // count entries: window length expressed in samples + guint count; // number of windows (0 = averaging disabled) + guint cap; // ring capacity = largest window in samples +} AvgWindows; + +// Per-series rolling state (one per GUI row / per CLI sensor). +typedef struct { + float *buf; // ring buffer of the last AvgWindows.cap samples + gdouble *sum; // running sum per window + gdouble *avg; // last computed average per window + guint n; // number of valid samples pushed + gboolean valid; // TRUE once at least one sample is present +} AvgSeries; + SensorInit* sensor_init_new(void); void sensor_init_free(SensorInit *s); -gboolean check_zen(); -gchar *cpu_model(); -guint get_core_count(); +gboolean check_zen(void); +gchar *cpu_model(void); +guint get_core_count(void); + +SensorDataStore* sensor_data_store_new(void); +void sensor_data_store_add_entry(SensorDataStore *store, gchar *entry); +gint sensor_data_store_drop_entry(SensorDataStore *store, gchar *entry); +void sensor_data_store_keep_time(SensorDataStore *store); +gint sensor_data_store_add_data(SensorDataStore *store, gchar *entry, float data); +void sensor_data_store_free(SensorDataStore *store); + +AvgWindows* avg_windows_parse(const gchar *spec, guint interval_ms); +void avg_windows_free(AvgWindows *w); +void avg_series_init(AvgSeries *s, const AvgWindows *w); +void avg_series_free(AvgSeries *s); +void avg_series_push(AvgSeries *s, const AvgWindows *w, float value); + +gchar** str_filter_parse(const gchar *spec); +gboolean str_filter_match(gchar * const *filter, const gchar *text); +void str_filter_free(gchar **filter); + extern gboolean display_coreid; + +#endif /* __ZENMONITOR_ZENMONITOR_H__ */ diff --git a/src/include/zenpower.h b/src/include/zenpower.h index 54978d6..6b89727 100644 --- a/src/include/zenpower.h +++ b/src/include/zenpower.h @@ -1,4 +1,4 @@ -gboolean zenpower_init(); -GSList* zenpower_get_sensors(); -void zenpower_update(); -void zenpower_clear_minmax(); +gboolean zenpower_init(void); +GSList* zenpower_get_sensors(void); +void zenpower_update(void); +void zenpower_clear_minmax(void); diff --git a/src/ss/msr.c b/src/ss/msr.c index 258d46c..0a187b3 100644 --- a/src/ss/msr.c +++ b/src/ss/msr.c @@ -12,7 +12,6 @@ #define MSR_PWR_PRINTF_FORMAT " %8.3f W" #define MSR_FID_PRINTF_FORMAT " %8.3f GHz" -#define MESUREMENT_TIME 0.1 // AMD PPR = https://www.amd.com/system/files/TechDocs/54945_PPR_Family_17h_Models_00h-0Fh.pdf // AMD OSRR = https://developer.amd.com/wp-content/resources/56255_3_03.PDF @@ -21,6 +20,12 @@ static guint cores = 0; static gdouble energy_unit = 0; static struct cpudev *cpu_dev_ids; +// Monotonic timestamp (microseconds) of the previous sample. RAPL energy is a +// running counter, so power is the energy delta divided by the real elapsed +// time between samples. Measuring over the natural refresh interval avoids a +// blocking sleep that would otherwise freeze the GTK main loop each tick. +static gint64 last_sample_time = 0; + static gint *msr_files = NULL; static gulong package_eng_b = 0; @@ -40,8 +45,8 @@ gfloat *core_fid_max; static gint open_msr(gshort devid) { - gchar msr_path[20]; - sprintf(msr_path, "/dev/cpu/%d/msr", devid); + gchar msr_path[32]; + snprintf(msr_path, sizeof msr_path, "/dev/cpu/%d/msr", devid); return open(msr_path, O_RDONLY); } @@ -81,7 +86,7 @@ gulong get_core_energy(gint core) { gdouble get_core_fid(gint core) { gdouble ratio; - gulong data; + gulong data, fdid; // By reverse-engineering Ryzen Master, we know that // this undocumented MSR is responsible for returning @@ -93,13 +98,17 @@ gdouble get_core_fid(gint core) { if (!read_msr(msr_files[core], 0xC0010293, &data)) return 0; - ratio = (gdouble)(data & 0xff) / (gdouble)((data >> 8) & 0x3F); + fdid = (data >> 8) & 0x3F; + if (fdid == 0) + return 0; + + ratio = (gdouble)(data & 0xff) / (gdouble)fdid; // The effective ratio is based on increments of 200 MHz. return ratio * 200.0 / 1000.0; } -gboolean msr_init() { +gboolean msr_init(void) { guint i; if (!check_zen()) @@ -110,7 +119,7 @@ gboolean msr_init() { return FALSE; cpu_dev_ids = get_cpu_dev_ids(); - msr_files = malloc(cores * sizeof (gint)); + msr_files = g_malloc(cores * sizeof (gint)); for (i = 0; i < cores; i++) { msr_files[i] = open_msr(cpu_dev_ids[i].cpuid); } @@ -119,16 +128,26 @@ gboolean msr_init() { if (energy_unit == 0) return FALSE; - core_eng_b = malloc(cores * sizeof (gulong)); - core_eng_a = malloc(cores * sizeof (gulong)); - core_power = malloc(cores * sizeof (gfloat)); - core_fid = malloc(cores * sizeof (gfloat)); - core_power_min = malloc(cores * sizeof (gfloat)); - core_power_max = malloc(cores * sizeof (gfloat)); - core_fid_min = malloc(cores * sizeof (gfloat)); - core_fid_max = malloc(cores * sizeof (gfloat)); + core_eng_b = g_malloc(cores * sizeof (gulong)); + core_eng_a = g_malloc(cores * sizeof (gulong)); + core_power = g_malloc(cores * sizeof (gfloat)); + core_fid = g_malloc(cores * sizeof (gfloat)); + core_power_min = g_malloc(cores * sizeof (gfloat)); + core_power_max = g_malloc(cores * sizeof (gfloat)); + core_fid_min = g_malloc(cores * sizeof (gfloat)); + core_fid_max = g_malloc(cores * sizeof (gfloat)); + + // Establish the energy/time baseline. Power stays at 0 until the first + // timer-driven msr_update() computes it over the elapsed interval. + last_sample_time = g_get_monotonic_time(); + package_eng_b = get_package_energy(); + package_power = 0; + for (i = 0; i < cores; i++) { + core_eng_b[i] = get_core_energy(i); + core_power[i] = 0; + core_fid[i] = get_core_fid(i); + } - msr_update(); memcpy(core_power_min, core_power, cores * sizeof (gfloat)); memcpy(core_power_max, core_power, cores * sizeof (gfloat)); memcpy(core_fid_min, core_fid, cores * sizeof (gfloat)); @@ -139,40 +158,45 @@ gboolean msr_init() { return TRUE; } -void msr_update() { +void msr_update(void) { guint i; + gint64 now; + gdouble elapsed; - package_eng_b = get_package_energy(); - for (i = 0; i < cores; i++) { - core_eng_b[i] = get_core_energy(i); - } - - usleep(MESUREMENT_TIME*1000000); + // Read the current energy counters and compute power over the time that + // has actually elapsed since the previous sample. No blocking sleep, so + // the GTK main loop stays responsive between refreshes. + now = g_get_monotonic_time(); + elapsed = (now - last_sample_time) / 1000000.0; package_eng_a = get_package_energy(); for (i = 0; i < cores; i++) { core_eng_a[i] = get_core_energy(i); } - if (package_eng_a >= package_eng_b) { - package_power = (package_eng_a - package_eng_b) * energy_unit / MESUREMENT_TIME; + if (elapsed > 0) { + if (package_eng_a >= package_eng_b) { + package_power = (package_eng_a - package_eng_b) * energy_unit / elapsed; - if (package_power < package_power_min) - package_power_min = package_power; - if (package_power > package_power_max) - package_power_max = package_power; - } + if (package_power < package_power_min) + package_power_min = package_power; + if (package_power > package_power_max) + package_power_max = package_power; + } - for (i = 0; i < cores; i++) { - if (core_eng_a[i] >= core_eng_b[i]) { - core_power[i] = (core_eng_a[i] - core_eng_b[i]) * energy_unit / MESUREMENT_TIME; + for (i = 0; i < cores; i++) { + if (core_eng_a[i] >= core_eng_b[i]) { + core_power[i] = (core_eng_a[i] - core_eng_b[i]) * energy_unit / elapsed; - if (core_power[i] < core_power_min[i]) - core_power_min[i] = core_power[i]; - if (core_power[i] > core_power_max[i]) - core_power_max[i] = core_power[i]; + if (core_power[i] < core_power_min[i]) + core_power_min[i] = core_power[i]; + if (core_power[i] > core_power_max[i]) + core_power_max[i] = core_power[i]; + } } + } + for (i = 0; i < cores; i++) { core_fid[i] = get_core_fid(i); if (core_fid[i] < core_fid_min[i]) @@ -180,9 +204,16 @@ void msr_update() { if (core_fid[i] > core_fid_max[i]) core_fid_max[i] = core_fid[i]; } + + // Current counters become the baseline for the next interval. + package_eng_b = package_eng_a; + for (i = 0; i < cores; i++) { + core_eng_b[i] = core_eng_a[i]; + } + last_sample_time = now; } -void msr_clear_minmax() { +void msr_clear_minmax(void) { guint i; package_power_min = package_power; @@ -195,7 +226,7 @@ void msr_clear_minmax() { } } -GSList* msr_get_sensors() { +GSList* msr_get_sensors(void) { GSList *list = NULL; SensorInit *data; guint i; diff --git a/src/ss/os.c b/src/ss/os.c index c2beb04..4060912 100644 --- a/src/ss/os.c +++ b/src/ss/os.c @@ -39,16 +39,16 @@ gboolean os_init(void) { return FALSE; cpu_dev_ids = get_cpu_dev_ids(); - frq_files = malloc(cores * sizeof (gchar*)); + frq_files = g_malloc(cores * sizeof (gchar*)); for (i = 0; i < cores; i++) { frq_files[i] = g_strdup_printf( "/sys/devices/system/cpu/cpu%d/cpufreq/scaling_cur_freq", cpu_dev_ids[i].cpuid); } - core_freq = malloc(cores * sizeof (gfloat)); - core_freq_min = malloc(cores * sizeof (gfloat)); - core_freq_max = malloc(cores * sizeof (gfloat)); + core_freq = g_malloc(cores * sizeof (gfloat)); + core_freq_min = g_malloc(cores * sizeof (gfloat)); + core_freq_max = g_malloc(cores * sizeof (gfloat)); os_update(); memcpy(core_freq_min, core_freq, cores * sizeof (gfloat)); diff --git a/src/ss/zenpower.c b/src/ss/zenpower.c index 2bf762b..dd4f746 100644 --- a/src/ss/zenpower.c +++ b/src/ss/zenpower.c @@ -82,7 +82,7 @@ static HwmonSensor *hwmon_sensor_new(HwmonSensorType *type, const gchar *dir, gi return s; } -gboolean zenpower_init() { +gboolean zenpower_init(void) { GDir *hwmon; const gchar *entry; gchar *name = NULL; @@ -93,7 +93,8 @@ gboolean zenpower_init() { return FALSE; while ((entry = g_dir_read_name(hwmon))) { - read_raw_hwmon_value(entry, "name", &name); + if (!read_raw_hwmon_value(entry, "name", &name)) + continue; if (strcmp(g_strchomp(name), "zenpower") == 0) { @@ -106,6 +107,7 @@ gboolean zenpower_init() { } g_free(name); + name = NULL; } if (zp_sensors == NULL) @@ -114,7 +116,7 @@ gboolean zenpower_init() { return TRUE; } -void zenpower_update() { +void zenpower_update(void) { gchar *tmp = NULL; GSList *node; HwmonSensor *sensor; @@ -141,7 +143,7 @@ void zenpower_update() { } } -void zenpower_clear_minmax() { +void zenpower_clear_minmax(void) { HwmonSensor *sensor; GSList *node; node = zp_sensors; @@ -153,7 +155,7 @@ void zenpower_clear_minmax() { } } -GSList* zenpower_get_sensors() { +GSList* zenpower_get_sensors(void) { GSList *list = NULL; HwmonSensor *sensor; GSList *node; diff --git a/src/sysfs.c b/src/sysfs.c index a6cc675..d5d7b09 100644 --- a/src/sysfs.c +++ b/src/sysfs.c @@ -35,16 +35,16 @@ struct cpudev* get_cpu_dev_ids(void) { guint cores; gboolean found; struct bitset seen = { 0 }; - int i; + guint i; cores = get_core_count(); - cpu_dev_ids = malloc(cores * sizeof (*cpu_dev_ids)); + cpu_dev_ids = g_malloc(cores * sizeof (*cpu_dev_ids)); for (i=0;i +#include +#include +#include +#include +#include +#include +#include +#include "zenmonitor.h" +#include "zenpower.h" +#include "msr.h" +#include "os.h" + +gboolean display_coreid = 0; +static gdouble delay = 0.5; +static gchar *file = ""; +static gchar *average_spec = NULL; +static gchar *snapshot_path = NULL; +static gchar *sensor_spec = NULL; +static gchar **sensor_filter = NULL; // NULL => every sensor is selected +static gint refresh_in_place = 0; +static gint output_once = 0; +static gint daemon_mode = 0; + +static SensorDataStore *store; +static volatile sig_atomic_t stop_requested = 0; + +static GOptionEntry options[] = { + {"file", 'f', G_OPTION_FLAG_NONE, G_OPTION_ARG_STRING, &file, + "Output collected readings to a CSV file on exit", "FILE"}, + {"delay", 'd', G_OPTION_FLAG_NONE, G_OPTION_ARG_DOUBLE, &delay, + "Interval between refreshes in seconds (default 0.5)", "SECONDS"}, + {"coreid", 'c', 0, G_OPTION_ARG_NONE, &display_coreid, + "Display core_id instead of core index", NULL}, + {"average", 'a', 0, G_OPTION_ARG_STRING, &average_spec, + "Rolling-average windows for --daemon output, e.g. 30s,1m,5m", "WINDOWS"}, + {"sensors", 'S', 0, G_OPTION_ARG_STRING, &sensor_spec, + "Only handle sensors whose label contains one of these comma-separated substrings (case-insensitive)", "SUBSTRINGS"}, + {"daemon", 'D', 0, G_OPTION_ARG_NONE, &daemon_mode, + "Run resident, writing a snapshot file each interval (keeps history for averages)", NULL}, + {"snapshot", 's', 0, G_OPTION_ARG_STRING, &snapshot_path, + "Snapshot file path for --daemon (default $XDG_RUNTIME_DIR/zenmonitor.snapshot)", "FILE"}, + {"refresh-in-place", 'r', 0, G_OPTION_ARG_NONE, &refresh_in_place, + "Redraw the readings in place instead of scrolling", NULL}, + {"output-once", 'o', 0, G_OPTION_ARG_NONE, &output_once, + "Output CPU information once and quit", NULL}, + {NULL}}; + +static SensorSource sensor_sources[] = { + { + "zenpower", + zenpower_init, zenpower_get_sensors, zenpower_update, zenpower_clear_minmax, + FALSE, NULL + }, + { + "msr", + msr_init, msr_get_sensors, msr_update, msr_clear_minmax, + FALSE, NULL + }, + { + "os", + os_init, os_get_sensors, os_update, os_clear_minmax, + FALSE, NULL + }, + { + NULL + } +}; + +// Async-signal-safe: just request a clean shutdown. The actual CSV flush, +// snapshot removal, and ncurses teardown happen back in the main loop. +static void request_stop(int signum) { + (void)signum; + stop_requested = 1; +} + +static void flush_to_csv(const gchar *path) { + FILE *csv; + guint sensors, samples, s, t; + + csv = fopen(path, "w"); + if (!csv) { + fprintf(stderr, "zenmonitor-cli: cannot open '%s': %s\n", + path, g_strerror(errno)); + return; + } + + sensors = store->labels->len; + samples = store->time->len; + + fprintf(csv, "time(epoch)"); + for (s = 0; s < sensors; s++) + fprintf(csv, ",%s", (gchar *)g_ptr_array_index(store->labels, s)); + fprintf(csv, "\n"); + + for (t = 0; t < samples; t++) { + struct timespec ts = g_array_index(store->time, struct timespec, t); + fprintf(csv, "%ld.%.9ld", (long)ts.tv_sec, (long)ts.tv_nsec); + + for (s = 0; s < sensors; s++) { + GArray *data = g_ptr_array_index(store->data, s); + float value = (t < data->len) ? g_array_index(data, float, t) : 0.0f; + fprintf(csv, ",%f", value); + } + fprintf(csv, "\n"); + } + + fclose(csv); +} + +#define sensor_selected(label) str_filter_match(sensor_filter, (label)) + +static void init_sensors(void) { + GSList *sensor; + SensorSource *source; + const SensorInit *data; + + for (source = sensor_sources; source->drv; source++) { + if (source->func_init()) { + source->sensors = source->func_get_sensors(); + if (source->sensors != NULL) { + guint selected = 0; + sensor = source->sensors; + while (sensor) { + data = (SensorInit *)sensor->data; + if (sensor_selected(data->label)) { + sensor_data_store_add_entry(store, data->label); + selected++; + } + sensor = sensor->next; + } + // Skip the whole source at update time when nothing matched, + // avoiding its per-tick sensor reads (e.g. the MSR preads). + source->enabled = (selected > 0); + } + } + } +} + +static void update_data(void) { + SensorSource *source; + GSList *node; + const SensorInit *sensorData; + int row = 1; // ncurses uses 1-based row indexing + + sensor_data_store_keep_time(store); + + for (source = sensor_sources; source->drv; source++) { + if (!source->enabled) + continue; + + source->func_update(); + if (!source->sensors) + continue; + + node = source->sensors; + while (node) { + sensorData = (SensorInit *)node->data; + if (!sensor_selected(sensorData->label)) { + node = node->next; + continue; + } + sensor_data_store_add_data(store, sensorData->label, *sensorData->value); + + if (refresh_in_place) { + mvprintw(row++, 0, "%s\t%f", sensorData->label, *sensorData->value); + } else { + printf("%s\t%f\n", sensorData->label, *sensorData->value); + } + node = node->next; + } + } + + if (refresh_in_place) + refresh(); + else + printf("\v"); +} + +static void start_watching(void) { + while (!stop_requested) { + update_data(); + if (output_once) + break; + usleep(delay * 1000 * 1000); + } +} + +// ---- Daemon mode ---------------------------------------------------------- +// Keeps rolling-average history in memory and rewrites a snapshot file each +// interval, so short-lived readers (e.g. an xfce4-genmon panel) can display a +// trailing average without holding any history themselves. + +static gchar *default_snapshot_path(void) { + const gchar *runtime = g_getenv("XDG_RUNTIME_DIR"); + if (runtime && *runtime) + return g_build_filename(runtime, "zenmonitor.snapshot", NULL); + return g_strdup("/tmp/zenmonitor.snapshot"); +} + +// Write the snapshot atomically: fill a temp file, then rename over the target. +static void write_snapshot(const gchar *path, const AvgWindows *avg, AvgSeries *series) { + SensorSource *source; + GSList *node; + const SensorInit *sensorData; + gchar *tmp; + FILE *out; + guint idx = 0, k; + + tmp = g_strdup_printf("%s.tmp", path); + out = fopen(tmp, "w"); + if (!out) { + fprintf(stderr, "zenmonitor-cli: cannot open '%s': %s\n", tmp, g_strerror(errno)); + g_free(tmp); + return; + } + + fprintf(out, "# updated %ld\n", (long)time(NULL)); + fprintf(out, "# sensor\tvalue"); + for (k = 0; k < avg->count; k++) + fprintf(out, "\t%s", avg->titles[k]); + fprintf(out, "\n"); + + for (source = sensor_sources; source->drv; source++) { + if (!source->enabled) + continue; + + source->func_update(); + if (!source->sensors) + continue; + + node = source->sensors; + while (node) { + sensorData = (SensorInit *)node->data; + if (!sensor_selected(sensorData->label)) { + node = node->next; + continue; + } + float value = *sensorData->value; + + avg_series_push(&series[idx], avg, value); + + fprintf(out, "%s\t%f", sensorData->label, value); + for (k = 0; k < avg->count; k++) { + if (series[idx].valid) + fprintf(out, "\t%f", series[idx].avg[k]); + else + fprintf(out, "\t"); + } + fprintf(out, "\n"); + + idx++; + node = node->next; + } + } + + fclose(out); + + if (rename(tmp, path) != 0) + fprintf(stderr, "zenmonitor-cli: cannot update '%s': %s\n", path, g_strerror(errno)); + + g_free(tmp); +} + +static int run_daemon(void) { + AvgWindows *avg; + AvgSeries *series; + guint n_sensors, i; + guint interval_ms; + + if (!snapshot_path) + snapshot_path = default_snapshot_path(); + + interval_ms = (guint)(delay * 1000.0 + 0.5); + avg = avg_windows_parse(average_spec, interval_ms); + + n_sensors = store->labels->len; + series = g_new0(AvgSeries, n_sensors ? n_sensors : 1); + for (i = 0; i < n_sensors; i++) + avg_series_init(&series[i], avg); + + fprintf(stderr, "zenmonitor-cli: daemon writing '%s' every %.3gs\n", + snapshot_path, delay); + + while (!stop_requested) { + write_snapshot(snapshot_path, avg, series); + if (output_once) + break; + usleep(delay * 1000 * 1000); + } + + if (!output_once) + unlink(snapshot_path); + + for (i = 0; i < n_sensors; i++) + avg_series_free(&series[i]); + g_free(series); + avg_windows_free(avg); + return EXIT_SUCCESS; +} + +int main(int argc, char *argv[]) { + GError *error = NULL; + GOptionContext *context; + gboolean write_csv; + int ret; + + context = g_option_context_new("- Zenmonitor command line interface"); + g_option_context_add_main_entries(context, options, NULL); + if (!g_option_context_parse(context, &argc, &argv, &error)) { + g_print("option parsing failed: %s\n", error->message); + exit(1); + } + + write_csv = (strcmp(file, "") != 0); + sensor_filter = str_filter_parse(sensor_spec); + + // Handle Ctrl-C/termination ourselves so ncurses is torn down, the CSV is + // written, and the daemon snapshot is cleaned up. + signal(SIGINT, request_stop); + signal(SIGTERM, request_stop); + + store = sensor_data_store_new(); + init_sensors(); + + if (daemon_mode) { + ret = run_daemon(); + sensor_data_store_free(store); + return ret; + } + + if (refresh_in_place) { + initscr(); + curs_set(0); + } + + start_watching(); + + if (refresh_in_place) + endwin(); + + if (write_csv) + flush_to_csv(file); + + sensor_data_store_free(store); + return EXIT_SUCCESS; +} diff --git a/src/zenmonitor-lib.c b/src/zenmonitor-lib.c new file mode 100644 index 0000000..eda1d86 --- /dev/null +++ b/src/zenmonitor-lib.c @@ -0,0 +1,332 @@ +#define _GNU_SOURCE /* for strcasestr */ +#include +#include +#include +#include "zenmonitor.h" + +#define AMD_STRING "AuthenticAMD" +#define ZEN_FAMILY 0x17 +#define ZEN3_FAMILY 0x19 + +// AMD PPR = https://www.amd.com/system/files/TechDocs/54945_PPR_Family_17h_Models_00h-0Fh.pdf + +gboolean check_zen(void) { + guint32 eax = 0, ebx = 0, ecx = 0, edx = 0, ext_family; + char vendor[13]; + + __get_cpuid(0, &eax, &ebx, &ecx, &edx); + + memcpy(vendor, &ebx, 4); + memcpy(vendor+4, &edx, 4); + memcpy(vendor+8, &ecx, 4); + vendor[12] = 0; + + if (strcmp(vendor, AMD_STRING) != 0){ + return FALSE; + } + + __get_cpuid(1, &eax, &ebx, &ecx, &edx); + + ext_family = ((eax >> 8) & 0xF) + ((eax >> 20) & 0xFF); + if (ext_family != ZEN_FAMILY && ext_family != ZEN3_FAMILY){ + return FALSE; + } + + return TRUE; +} + +gchar *cpu_model(void) { + guint32 eax = 0, ebx = 0, ecx = 0, edx = 0; + char model[49]; + + // AMD PPR: page 65-68 - CPUID_Fn80000002_EAX-CPUID_Fn80000004_EDX + __get_cpuid(0x80000002, &eax, &ebx, &ecx, &edx); + memcpy(model, &eax, 4); + memcpy(model+4, &ebx, 4); + memcpy(model+8, &ecx, 4); + memcpy(model+12, &edx, 4); + + __get_cpuid(0x80000003, &eax, &ebx, &ecx, &edx); + memcpy(model+16, &eax, 4); + memcpy(model+20, &ebx, 4); + memcpy(model+24, &ecx, 4); + memcpy(model+28, &edx, 4); + + __get_cpuid(0x80000004, &eax, &ebx, &ecx, &edx); + memcpy(model+32, &eax, 4); + memcpy(model+36, &ebx, 4); + memcpy(model+40, &ecx, 4); + memcpy(model+44, &edx, 4); + + model[48] = 0; + return g_strdup(g_strchomp(model)); +} + +guint get_core_count(void) { + guint eax = 0, ebx = 0, ecx = 0, edx = 0; + guint logical_cpus, threads_per_core; + + // AMD PPR: page 57 - CPUID_Fn00000001_EBX + __get_cpuid(1, &eax, &ebx, &ecx, &edx); + logical_cpus = (ebx >> 16) & 0xFF; + + // AMD PPR: page 82 - CPUID_Fn8000001E_EBX + __get_cpuid(0x8000001E, &eax, &ebx, &ecx, &edx); + // ThreadsPerCore is zero-based, so add 1 (always >= 1). + threads_per_core = ((ebx >> 8) & 0xF) + 1; + + return logical_cpus / threads_per_core; +} + +SensorInit *sensor_init_new(void) { + return g_new0(SensorInit, 1); +} + +void sensor_init_free(SensorInit *s) { + if (s) { + g_free(s->label); + g_free(s->hint); + g_free(s); + } +} + +// Time-series store used by the CLI to accumulate readings for CSV export. +// labels: GPtrArray of gchar* sensor names (borrowed, owned by the sensors). +// data: GPtrArray of GArray, one series per label. +// time: GArray, one timestamp per sample row. +SensorDataStore *sensor_data_store_new(void) { + SensorDataStore *ret; + + ret = g_new0(SensorDataStore, 1); + ret->labels = g_ptr_array_new(); + ret->data = g_ptr_array_new(); + ret->time = g_array_new(FALSE, TRUE, sizeof(struct timespec)); + + return ret; +} + +void sensor_data_store_add_entry(SensorDataStore *store, gchar *entry) { + GArray *data; + data = g_array_new(TRUE, TRUE, sizeof(float)); + + g_ptr_array_add(store->labels, entry); + g_ptr_array_add(store->data, data); +} + +gint sensor_data_store_drop_entry(SensorDataStore *store, gchar *entry) { + guint index = 0; + if (!g_ptr_array_find(store->labels, entry, &index)) + return 1; + + g_ptr_array_remove_index(store->labels, index); + g_ptr_array_remove_index(store->data, index); + + return 0; +} + +void sensor_data_store_keep_time(SensorDataStore *store) { + struct timespec ts; + timespec_get(&ts, TIME_UTC); + g_array_append_val(store->time, ts); +} + +gint sensor_data_store_add_data(SensorDataStore *store, gchar *entry, float value) { + guint index = 0; + if (!g_ptr_array_find(store->labels, entry, &index)) + return 1; + + GArray *data = g_ptr_array_index(store->data, index); + g_array_append_val(data, value); + + return 0; +} + +void sensor_data_store_free(SensorDataStore *store) { + if (store) { + guint i; + for (i = 0; i < store->data->len; i++) + g_array_free(g_ptr_array_index(store->data, i), TRUE); + + g_array_free(store->time, TRUE); + g_ptr_array_free(store->labels, TRUE); + g_ptr_array_free(store->data, TRUE); + g_free(store); + } +} + +// ---- Rolling averages ----------------------------------------------------- +// A window given in seconds is converted to a sample count using the caller's +// sampling interval, so the same "5m" means 5 minutes regardless of how often +// the GUI or CLI polls. Each series keeps a ring buffer sized to the largest +// window plus a running sum per window, yielding an exact windowed mean in +// O(windows) per push with no re-summing. + +static gboolean parse_window_seconds(const gchar *tok, gdouble *out_seconds) { + gchar *end = NULL; + gdouble val, mult; + + val = g_ascii_strtod(tok, &end); + if (end == tok) + return FALSE; + + while (*end == ' ') + end++; + + switch (*end) { + case '\0': + case 's': case 'S': mult = 1.0; break; + case 'm': case 'M': mult = 60.0; break; + case 'h': case 'H': mult = 3600.0; break; + default: return FALSE; + } + + *out_seconds = val * mult; + return TRUE; +} + +AvgWindows *avg_windows_parse(const gchar *spec, guint interval_ms) { + AvgWindows *w; + gchar **tokens; + guint i; + + w = g_new0(AvgWindows, 1); + if (!spec || *spec == '\0' || interval_ms == 0) + return w; // count == 0: averaging disabled + + tokens = g_strsplit(spec, ",", -1); + w->titles = g_new0(gchar *, g_strv_length(tokens)); + w->samples = g_new0(guint, g_strv_length(tokens)); + + for (i = 0; tokens[i]; i++) { + gchar *tok = g_strstrip(tokens[i]); + gdouble seconds; + guint samples; + + if (*tok == '\0') + continue; + + if (!parse_window_seconds(tok, &seconds) || seconds <= 0) { + g_printerr("zenmonitor: ignoring invalid average window '%s'\n", tok); + continue; + } + + samples = (guint)((seconds * 1000.0) / interval_ms + 0.5); + if (samples < 1) + samples = 1; + + w->titles[w->count] = g_strdup_printf("Avg %s", tok); + w->samples[w->count] = samples; + if (samples > w->cap) + w->cap = samples; + w->count++; + } + + g_strfreev(tokens); + return w; +} + +void avg_windows_free(AvgWindows *w) { + guint i; + if (!w) + return; + for (i = 0; i < w->count; i++) + g_free(w->titles[i]); + g_free(w->titles); + g_free(w->samples); + g_free(w); +} + +void avg_series_init(AvgSeries *s, const AvgWindows *w) { + s->n = 0; + s->valid = FALSE; + if (w->count == 0) { + s->buf = NULL; + s->sum = NULL; + s->avg = NULL; + return; + } + s->buf = g_new0(float, w->cap); + s->sum = g_new0(gdouble, w->count); + s->avg = g_new0(gdouble, w->count); +} + +void avg_series_free(AvgSeries *s) { + if (!s) + return; + g_free(s->buf); + g_free(s->sum); + g_free(s->avg); + s->buf = NULL; + s->sum = NULL; + s->avg = NULL; +} + +// ---- Substring filter ----------------------------------------------------- +// Parse a comma-separated list of substrings into a NULL-terminated array +// (empty tokens dropped). Returns NULL for an empty/unset spec, meaning +// "match everything". Free with str_filter_free. + +gchar **str_filter_parse(const gchar *spec) { + gchar **raw; + GPtrArray *arr; + guint i; + + if (!spec || *spec == '\0') + return NULL; + + raw = g_strsplit(spec, ",", -1); + arr = g_ptr_array_new(); + for (i = 0; raw[i]; i++) { + gchar *tok = g_strstrip(raw[i]); + if (*tok) + g_ptr_array_add(arr, g_strdup(tok)); + } + g_strfreev(raw); + + if (arr->len == 0) { + g_ptr_array_free(arr, TRUE); + return NULL; + } + + g_ptr_array_add(arr, NULL); + return (gchar **)g_ptr_array_free(arr, FALSE); +} + +gboolean str_filter_match(gchar * const *filter, const gchar *text) { + guint i; + if (!filter) + return TRUE; + for (i = 0; filter[i]; i++) + if (strcasestr(text, filter[i])) + return TRUE; + return FALSE; +} + +void str_filter_free(gchar **filter) { + if (filter) + g_strfreev(filter); +} + +void avg_series_push(AvgSeries *s, const AvgWindows *w, float value) { + guint k; + + // Skip error readings so a transient failure doesn't skew the window. + if (w->count == 0 || value == ERROR_VALUE) + return; + + for (k = 0; k < w->count; k++) { + guint window = w->samples[k]; + guint denom; + + s->sum[k] += value; + if (s->n >= window) + s->sum[k] -= s->buf[(s->n - window) % w->cap]; + + denom = (s->n + 1 < window) ? (s->n + 1) : window; + s->avg[k] = s->sum[k] / denom; + } + + s->buf[s->n % w->cap] = value; + s->n++; + s->valid = TRUE; +} diff --git a/src/zenmonitor.c b/src/zenmonitor.c index d02c510..6c85302 100644 --- a/src/zenmonitor.c +++ b/src/zenmonitor.c @@ -1,6 +1,4 @@ #include -#include -#include #include #include "zenmonitor.h" #include "zenpower.h" @@ -8,81 +6,6 @@ #include "os.h" #include "gui.h" -#define AMD_STRING "AuthenticAMD" -#define ZEN_FAMILY 0x17 - -// AMD PPR = https://www.amd.com/system/files/TechDocs/54945_PPR_Family_17h_Models_00h-0Fh.pdf - -gboolean check_zen() { - guint32 eax = 0, ebx = 0, ecx = 0, edx = 0, ext_family; - char vendor[13]; - - __get_cpuid(0, &eax, &ebx, &ecx, &edx); - - memcpy(vendor, &ebx, 4); - memcpy(vendor+4, &edx, 4); - memcpy(vendor+8, &ecx, 4); - vendor[12] = 0; - - if (strcmp(vendor, AMD_STRING) != 0){ - return FALSE; - } - - __get_cpuid(1, &eax, &ebx, &ecx, &edx); - - ext_family = ((eax >> 8) & 0xF) + ((eax >> 20) & 0xFF); - if (ext_family != ZEN_FAMILY){ - return FALSE; - } - - return TRUE; -} - -gchar *cpu_model() { - guint32 eax = 0, ebx = 0, ecx = 0, edx = 0; - char model[48]; - - // AMD PPR: page 65-68 - CPUID_Fn80000002_EAX-CPUID_Fn80000004_EDX - __get_cpuid(0x80000002, &eax, &ebx, &ecx, &edx); - memcpy(model, &eax, 4); - memcpy(model+4, &ebx, 4); - memcpy(model+8, &ecx, 4); - memcpy(model+12, &edx, 4); - - __get_cpuid(0x80000003, &eax, &ebx, &ecx, &edx); - memcpy(model+16, &eax, 4); - memcpy(model+20, &ebx, 4); - memcpy(model+24, &ecx, 4); - memcpy(model+28, &edx, 4); - - __get_cpuid(0x80000004, &eax, &ebx, &ecx, &edx); - memcpy(model+32, &eax, 4); - memcpy(model+36, &ebx, 4); - memcpy(model+40, &ecx, 4); - memcpy(model+44, &edx, 4); - - model[48] = 0; - return g_strdup(g_strchomp(model)); -} - -guint get_core_count() { - guint eax = 0, ebx = 0, ecx = 0, edx = 0; - guint logical_cpus, threads_per_code; - - // AMD PPR: page 57 - CPUID_Fn00000001_EBX - __get_cpuid(1, &eax, &ebx, &ecx, &edx); - logical_cpus = (ebx >> 16) & 0xFF; - - // AMD PPR: page 82 - CPUID_Fn8000001E_EBX - __get_cpuid(0x8000001E, &eax, &ebx, &ecx, &edx); - threads_per_code = ((ebx >> 8) & 0xF) + 1; - - if (threads_per_code == 0) - return logical_cpus; - - return logical_cpus / threads_per_code; -} - static SensorSource sensor_sources[] = { { "zenpower", @@ -104,23 +27,15 @@ static SensorSource sensor_sources[] = { } }; -SensorInit *sensor_init_new() { - return g_new0(SensorInit, 1); -} - -void sensor_init_free(SensorInit *s) { - if (s) { - g_free(s->label); - g_free(s->hint); - g_free(s); - } -} - gboolean display_coreid = 0; +static gchar *average_spec = NULL; +static gchar *average_only_spec = NULL; static GOptionEntry options[] = { { "coreid", 'c', 0, G_OPTION_ARG_NONE, &display_coreid, "Display core_id instead of core index", NULL }, + { "average", 'a', 0, G_OPTION_ARG_STRING, &average_spec, "Show rolling-average columns for the given comma-separated windows (e.g. 30s,1m,5m)", "WINDOWS" }, + { "average-only", 'A', 0, G_OPTION_ARG_STRING, &average_only_spec, "Only average sensors whose label contains one of these comma-separated substrings (e.g. power,temp)", "SUBSTRINGS" }, { NULL } }; @@ -137,5 +52,7 @@ int main (int argc, char *argv[]) exit (1); } + gui_set_averages(average_spec); + gui_set_average_filter(average_only_spec); start_gui(sensor_sources); } From ffce0fb49944894d158beecb431ae3ff149c17c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 23:18:37 +0000 Subject: [PATCH 2/4] Add runtime-configurable refresh interval to the GUI; tidy README Ports the update-interval control from the zenmonitor3 fork onto this codebase: a header-bar button opens an "Update Interval" dialog (spin button, 50-60000 ms) and a new --interval/-i option sets the initial value (default now 1000 ms, previously a fixed 200 ms). The setting is deliberately not persisted - all configuration stays on the command line. Because the interval also converts average windows into sample counts, changing it re-parses the windows against the new cadence and resets the average history (samples taken at different cadences cannot share a window); the Avg columns restart from "---". The window spec is retained unparsed for that purpose. Also fixed while reviewing the change: - apply_interval() only reschedules the timer if one is running, so using the dialog on the "Zen CPU not detected" path cannot start a stray timer or leave a stale source ID. - A negative --interval clamps to the minimum instead of wrapping through the guint cast to the maximum. - CLI: --delay below 0.05s is clamped with a warning; 0 would busy-loop and a negative value wraps through the usleep()/window-math casts. README: document --interval, point the clone URL at this repository, and drop the AUR section (it packages the original upstream project). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BhfyG1chPR37duryk96VTa --- README.md | 6 +-- src/gui.c | 113 ++++++++++++++++++++++++++++++++++++++++--- src/include/gui.h | 1 + src/zenmonitor-cli.c | 7 +++ src/zenmonitor.c | 4 ++ 5 files changed, 120 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index f839974..17c99ca 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ Alternatively, you can set capabilities to zenmonitor executable: `sudo setcap c ``--coreid`` - Display core_id instead of core index +``--interval MS`` - Initial refresh interval in milliseconds (50-60000, default 1000). The interval is also adjustable while running via the "Update interval" button in the header bar; changing it resets the rolling averages. + ``--average WINDOWS`` - Show additional rolling-average columns for the given comma-separated time windows, e.g. ``--average 30s,1m,5m`` (suffixes: ``s`` seconds, ``m`` minutes, ``h`` hours; a bare number is seconds). Omit to show no average columns. ``--average-only SUBSTRINGS`` - Only average sensors whose label contains one of these comma-separated substrings (case-insensitive), e.g. ``--average-only power,temp``. Non-matching rows still show Value/Min/Max but leave the average cells blank. Omit to average every sensor. @@ -106,11 +108,9 @@ sudo modprobe msr sudo bash -c 'echo "msr" > /etc/modules-load.d/msr.conf' sudo apt install build-essential libgtk-3-dev git cd ~ -git clone https://github.com/ocerman/zenmonitor +git clone https://github.com/HonsW/zenmonitor cd zenmonitor make sudo make install sudo make install-polkit ``` -## Setup on Arch -You may use the AUR package [zenmonitor-git](https://aur.archlinux.org/packages/zenmonitor-git/) to install via [traditional method](https://wiki.archlinux.org/index.php/Arch_User_Repository) or using an AUR helper (like yay) diff --git a/src/gui.c b/src/gui.c index 6b45b0a..cdc5343 100644 --- a/src/gui.c +++ b/src/gui.c @@ -3,9 +3,13 @@ #include "zenmonitor.h" #include "gui.h" -// Sensor refresh cadence. Used both for the GTK timer and to translate the -// configured average windows (in seconds) into a number of samples. -#define REFRESH_INTERVAL_MS 200 +// Default sensor refresh cadence (ms). Overridable at startup via --interval +// and at runtime via the "Update Interval" dialog. The interval also converts +// the configured average windows (seconds) into a sample count, so changing it +// re-parses the windows and resets the average history. +#define DEFAULT_INTERVAL_MS 1000 +#define MIN_INTERVAL_MS 50 +#define MAX_INTERVAL_MS 60000 GtkWidget *window; @@ -23,23 +27,42 @@ static const guint defaultHeight = 350; #define COLUMN_MAX 4 #define COLUMN_AVG_BASE 5 +// Current refresh cadence in ms (settable via --interval and the dialog). +static guint refresh_interval_ms = DEFAULT_INTERVAL_MS; + // Rolling-average configuration (count == 0 by default -> no average columns). static AvgWindows *avg = NULL; static AvgSeries *series = NULL; // one per tree row static gboolean *row_avg = NULL; // whether each row is averaged (--average-only) static gchar **avg_filter = NULL; // NULL => average every sensor +static gchar *avg_spec = NULL; // retained window spec, re-parsed on interval change static guint n_rows = 0; static guint avg_count(void) { return avg ? avg->count : 0; } -// Parse a comma-separated list of window durations (e.g. "30s,1m,5m"). -// Passing NULL or an empty string leaves averaging disabled. -void gui_set_averages(const gchar *spec) { +// (Re)build the average windows from the retained spec and current interval. +static void parse_averages(void) { if (avg) avg_windows_free(avg); - avg = avg_windows_parse(spec, REFRESH_INTERVAL_MS); + avg = avg_windows_parse(avg_spec, refresh_interval_ms); +} + +// Set the initial refresh interval (clamped). Called before start_gui(). +void gui_set_interval(guint ms) { + if (ms < MIN_INTERVAL_MS) + ms = MIN_INTERVAL_MS; + if (ms > MAX_INTERVAL_MS) + ms = MAX_INTERVAL_MS; + refresh_interval_ms = ms; +} + +// Retain the window spec (e.g. "30s,1m,5m"); parsing happens in start_gui once +// the interval is known. NULL/empty leaves averaging disabled. +void gui_set_averages(const gchar *spec) { + g_free(avg_spec); + avg_spec = (spec && *spec) ? g_strdup(spec) : NULL; } // Restrict which sensors get averaged to those whose label contains one of the @@ -241,6 +264,72 @@ static void clear_btn_clicked(GtkButton *button, gpointer user_data) { } } +// Re-parse the average windows for the new interval and reset every averaged +// row's history (samples taken at different cadences can't share a window). +static void reset_averages(void) { + GtkTreeIter iter; + guint r = 0, k; + + if (avg_count() == 0) + return; + + parse_averages(); + + if (!series || !gtk_tree_model_get_iter_first(model, &iter)) + return; + + do { + if (r < n_rows && row_avg[r]) { + avg_series_free(&series[r]); + avg_series_init(&series[r], avg); + for (k = 0; k < avg->count; k++) + gtk_list_store_set(GTK_LIST_STORE(model), &iter, + COLUMN_AVG_BASE + k, " --- ", -1); + } + r++; + } while (gtk_tree_model_iter_next(model, &iter)); +} + +static void apply_interval(guint ms) { + if (ms == refresh_interval_ms) + return; + + refresh_interval_ms = ms; + reset_averages(); // rescale windows and restart the average history + + // Reschedule only if monitoring is actually running (timeout is 0 when no + // Zen CPU was detected and sensors were never initialised). + if (timeout) { + g_source_remove(timeout); + timeout = g_timeout_add(refresh_interval_ms, update_data, NULL); + } +} + +static void interval_btn_clicked(GtkButton *button, gpointer user_data) { + GtkWidget *dialog, *content, *box, *label, *spin; + + dialog = gtk_dialog_new_with_buttons("Update Interval", GTK_WINDOW(window), + GTK_DIALOG_MODAL | GTK_DIALOG_DESTROY_WITH_PARENT, + "_Cancel", GTK_RESPONSE_CANCEL, + "_OK", GTK_RESPONSE_OK, NULL); + content = gtk_dialog_get_content_area(GTK_DIALOG(dialog)); + + box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + gtk_container_set_border_width(GTK_CONTAINER(box), 12); + label = gtk_label_new("Update interval (ms):"); + spin = gtk_spin_button_new_with_range(MIN_INTERVAL_MS, MAX_INTERVAL_MS, 50); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(spin), refresh_interval_ms); + gtk_box_pack_start(GTK_BOX(box), label, FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(box), spin, FALSE, FALSE, 0); + gtk_container_add(GTK_CONTAINER(content), box); + gtk_widget_show_all(dialog); + + if (gtk_dialog_run(GTK_DIALOG(dialog)) == GTK_RESPONSE_OK) + apply_interval((guint)gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(spin))); + + gtk_widget_destroy(dialog); +} + static gboolean mid_search_eq_func(GtkTreeModel *model, gint column, const gchar *key, GtkTreeIter *iter) { gchar *iter_string = NULL, *lc_iter_string = NULL, *lc_key = NULL; gboolean result; @@ -279,6 +368,7 @@ static void resize_to_treeview(GtkWindow* window, GtkTreeView* treeview) { int start_gui (SensorSource *ss) { GtkWidget *about_btn; GtkWidget *clear_btn; + GtkWidget *interval_btn; GtkWidget *box; GtkWidget *header; GtkWidget *treeview; @@ -312,9 +402,15 @@ int start_gui (SensorSource *ss) { gtk_container_add(GTK_CONTAINER(box), clear_btn); gtk_widget_set_tooltip_text(clear_btn, "Clear Min/Max"); + interval_btn = gtk_button_new(); + gtk_container_add(GTK_CONTAINER(interval_btn), gtk_image_new_from_icon_name("preferences-system", GTK_ICON_SIZE_BUTTON)); + gtk_container_add(GTK_CONTAINER(box), interval_btn); + gtk_widget_set_tooltip_text(interval_btn, "Update interval"); + gtk_header_bar_pack_start(GTK_HEADER_BAR(header), box); g_signal_connect(about_btn, "clicked", G_CALLBACK(about_btn_clicked), NULL); g_signal_connect(clear_btn, "clicked", G_CALLBACK(clear_btn_clicked), NULL); + g_signal_connect(interval_btn, "clicked", G_CALLBACK(interval_btn_clicked), NULL); g_signal_connect(window, "destroy", G_CALLBACK(gtk_main_quit), NULL); vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); @@ -325,6 +421,7 @@ int start_gui (SensorSource *ss) { gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW (sw), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC); gtk_box_pack_start(GTK_BOX (vbox), sw, TRUE, TRUE, 0); + parse_averages(); // build windows from the spec + initial interval model = create_model(); treeview = gtk_tree_view_new_with_model(model); gtk_tree_view_set_tooltip_column(GTK_TREE_VIEW(treeview), COLUMN_HINT); @@ -344,7 +441,7 @@ int start_gui (SensorSource *ss) { init_sensors(); resize_to_treeview(GTK_WINDOW(window), GTK_TREE_VIEW(treeview)); - timeout = g_timeout_add(REFRESH_INTERVAL_MS, update_data, NULL); + timeout = g_timeout_add(refresh_interval_ms, update_data, NULL); } else{ dialog = gtk_message_dialog_new(GTK_WINDOW (window), diff --git a/src/include/gui.h b/src/include/gui.h index 01f1db6..7868956 100644 --- a/src/include/gui.h +++ b/src/include/gui.h @@ -1,3 +1,4 @@ int start_gui(SensorSource *ss); +void gui_set_interval(guint ms); void gui_set_averages(const gchar *spec); void gui_set_average_filter(const gchar *spec); diff --git a/src/zenmonitor-cli.c b/src/zenmonitor-cli.c index 6d2a587..43e688d 100644 --- a/src/zenmonitor-cli.c +++ b/src/zenmonitor-cli.c @@ -315,6 +315,13 @@ int main(int argc, char *argv[]) { write_csv = (strcmp(file, "") != 0); sensor_filter = str_filter_parse(sensor_spec); + // Guard against a zero/negative interval: 0 would busy-loop, and a + // negative value wraps through the usleep() and window-math casts. + if (delay < 0.05) { + fprintf(stderr, "zenmonitor-cli: --delay too small, using 0.05s\n"); + delay = 0.05; + } + // Handle Ctrl-C/termination ourselves so ncurses is torn down, the CSV is // written, and the daemon snapshot is cleaned up. signal(SIGINT, request_stop); diff --git a/src/zenmonitor.c b/src/zenmonitor.c index 6c85302..cea5b05 100644 --- a/src/zenmonitor.c +++ b/src/zenmonitor.c @@ -28,12 +28,14 @@ static SensorSource sensor_sources[] = { }; gboolean display_coreid = 0; +static gint interval_ms = 1000; static gchar *average_spec = NULL; static gchar *average_only_spec = NULL; static GOptionEntry options[] = { { "coreid", 'c', 0, G_OPTION_ARG_NONE, &display_coreid, "Display core_id instead of core index", NULL }, + { "interval", 'i', 0, G_OPTION_ARG_INT, &interval_ms, "Initial refresh interval in ms (50-60000, default 1000; adjustable at runtime)", "MS" }, { "average", 'a', 0, G_OPTION_ARG_STRING, &average_spec, "Show rolling-average columns for the given comma-separated windows (e.g. 30s,1m,5m)", "WINDOWS" }, { "average-only", 'A', 0, G_OPTION_ARG_STRING, &average_only_spec, "Only average sensors whose label contains one of these comma-separated substrings (e.g. power,temp)", "SUBSTRINGS" }, { NULL } @@ -52,6 +54,8 @@ int main (int argc, char *argv[]) exit (1); } + // A negative value must clamp to the minimum, not wrap around the cast. + gui_set_interval(interval_ms > 0 ? (guint)interval_ms : 0); gui_set_averages(average_spec); gui_set_average_filter(average_only_spec); start_gui(sensor_sources); From 3e53d586df05f4cab165bb8d0ae3616005dd0ffe Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 23:28:24 +0000 Subject: [PATCH 3/4] Bump version to 1.5.0; point About at this repo; minor cleanups - About dialog now links this repository (original-author credit unchanged). - VERSION 1.4.2 -> 1.5.0, reflecting the non-blocking sampling, Zen 3/4 detection, headless CLI, rolling averages, and configurable refresh interval added since 1.4.2. - Make the msr/os sensor arrays and zenpower's sensor list static: they are only referenced within their own files (external access goes through SensorInit pointers), so they no longer leak into the global namespace. - update_data() clears the stored timeout ID when it self-removes, so a later apply_interval() cannot g_source_remove() a dead source. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BhfyG1chPR37duryk96VTa --- src/gui.c | 12 +++++++++--- src/include/zenmonitor.h | 2 +- src/ss/msr.c | 18 +++++++++--------- src/ss/os.c | 6 +++--- src/ss/zenpower.c | 2 +- 5 files changed, 23 insertions(+), 17 deletions(-) diff --git a/src/gui.c b/src/gui.c index cdc5343..3d998fd 100644 --- a/src/gui.c +++ b/src/gui.c @@ -181,11 +181,17 @@ static gboolean update_data (gpointer data) { const SensorInit *sensorData; guint row = 0; - if (model == NULL) + // On self-removal, clear the stored ID so apply_interval() won't + // g_source_remove() a source that no longer exists. + if (model == NULL) { + timeout = 0; return G_SOURCE_REMOVE; + } - if (!gtk_tree_model_get_iter_first (model, &iter)) + if (!gtk_tree_model_get_iter_first (model, &iter)) { + timeout = 0; return G_SOURCE_REMOVE; + } for (source = sensor_sources; source->drv; source++) { if (!source->enabled) @@ -238,7 +244,7 @@ static void add_columns (GtkTreeView *treeview) { static void about_btn_clicked(GtkButton *button, gpointer user_data) { GtkWidget *dialog; - const gchar *website = "https://github.com/ocerman/zenmonitor"; + const gchar *website = "https://github.com/HonsW/zenmonitor"; const gchar *msg = "Zen Monitor %s\n" "Monitoring software for AMD Zen-based CPUs\n" "%s\n\n" diff --git a/src/include/zenmonitor.h b/src/include/zenmonitor.h index 90fbd0e..c82ff7d 100644 --- a/src/include/zenmonitor.h +++ b/src/include/zenmonitor.h @@ -5,7 +5,7 @@ #include #define ERROR_VALUE -999.0 -#define VERSION "1.4.2" +#define VERSION "1.5.0" typedef struct { diff --git a/src/ss/msr.c b/src/ss/msr.c index 0a187b3..8d3c33d 100644 --- a/src/ss/msr.c +++ b/src/ss/msr.c @@ -33,15 +33,15 @@ static gulong package_eng_a = 0; static gulong *core_eng_b = NULL; static gulong *core_eng_a = NULL; -gfloat package_power; -gfloat package_power_min; -gfloat package_power_max; -gfloat *core_power; -gfloat *core_fid; -gfloat *core_power_min; -gfloat *core_power_max; -gfloat *core_fid_min; -gfloat *core_fid_max; +static gfloat package_power; +static gfloat package_power_min; +static gfloat package_power_max; +static gfloat *core_power; +static gfloat *core_fid; +static gfloat *core_power_min; +static gfloat *core_power_max; +static gfloat *core_fid_min; +static gfloat *core_fid_max; static gint open_msr(gshort devid) { diff --git a/src/ss/os.c b/src/ss/os.c index 4060912..4d4ae71 100644 --- a/src/ss/os.c +++ b/src/ss/os.c @@ -11,9 +11,9 @@ static gchar **frq_files = NULL; static guint cores; static struct cpudev *cpu_dev_ids; -gfloat *core_freq; -gfloat *core_freq_min; -gfloat *core_freq_max; +static gfloat *core_freq; +static gfloat *core_freq_min; +static gfloat *core_freq_max; static gdouble get_frequency(guint corei) { gchar *data; diff --git a/src/ss/zenpower.c b/src/ss/zenpower.c index dd4f746..dfb8614 100644 --- a/src/ss/zenpower.c +++ b/src/ss/zenpower.c @@ -4,7 +4,7 @@ #include "zenmonitor.h" #include "zenpower.h" -GSList *zp_sensors = NULL; +static GSList *zp_sensors = NULL; static int nodes = 0; typedef struct From 5ded6415730d84ad9aa04aa63fa51e58312ef0d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 23:37:32 +0000 Subject: [PATCH 4/4] Stream CSV output instead of dumping at exit The CLI's --file mode previously accumulated every sample of every sensor in memory and wrote the CSV only on a clean exit. That grows without bound (hundreds of MB/day at high sample rates) and loses the entire log on any unclean death - the exact runs a hardware monitor is usually logging to diagnose. Now the header is written once after sensor init and one row is appended and flushed per refresh: memory use is constant, tail -f works on the live file, and the log survives up to the last row on crash/SIGKILL (verified by killing -9 mid-run). If the file cannot be opened the CLI fails fast at startup instead of discovering it at exit, and --file now warns that it is ignored in --daemon mode instead of being silently dropped. The SensorDataStore machinery existed only to back the exit dump, so it is removed from zenmonitor-lib.c and the header (along with the now-unneeded time.h includes); the daemon takes its sensor count from the filter pass in init_sensors instead. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BhfyG1chPR37duryk96VTa --- README.md | 5 +- src/include/zenmonitor.h | 14 ----- src/zenmonitor-cli.c | 112 ++++++++++++++++++++++----------------- src/zenmonitor-lib.c | 65 ----------------------- 4 files changed, 66 insertions(+), 130 deletions(-) diff --git a/README.md b/README.md index 17c99ca..048179b 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,10 @@ sudo make install-cli ``` It reuses the same sensor backends and supports ``--delay SECONDS`` (poll interval), ``--coreid``, ``--refresh-in-place`` (redraw in place), ``--output-once``, -and ``--file FILE`` (dump all collected readings to CSV on exit). +and ``--file FILE`` (stream readings to a CSV file, one row per refresh). The CSV +is appended and flushed as it goes, so memory use stays constant, ``tail -f`` +works on it, and the log survives up to the last row if the machine crashes - +which makes it suitable for long high-frequency captures. ``--sensors SUBSTRINGS`` limits output to sensors whose label contains one of the given comma-separated substrings (case-insensitive), e.g. diff --git a/src/include/zenmonitor.h b/src/include/zenmonitor.h index c82ff7d..adcf029 100644 --- a/src/include/zenmonitor.h +++ b/src/include/zenmonitor.h @@ -1,7 +1,6 @@ #ifndef __ZENMONITOR_ZENMONITOR_H__ #define __ZENMONITOR_ZENMONITOR_H__ -#include #include #define ERROR_VALUE -999.0 @@ -28,12 +27,6 @@ typedef struct { GSList *sensors; } SensorSource; -typedef struct { - GPtrArray *labels; - GPtrArray *data; - GArray *time; -} SensorDataStore; - // Rolling-average configuration, shared by the GUI columns and the CLI daemon. typedef struct { gchar **titles; // count entries: column/label heading, e.g. "Avg 1m" @@ -57,13 +50,6 @@ gboolean check_zen(void); gchar *cpu_model(void); guint get_core_count(void); -SensorDataStore* sensor_data_store_new(void); -void sensor_data_store_add_entry(SensorDataStore *store, gchar *entry); -gint sensor_data_store_drop_entry(SensorDataStore *store, gchar *entry); -void sensor_data_store_keep_time(SensorDataStore *store); -gint sensor_data_store_add_data(SensorDataStore *store, gchar *entry, float data); -void sensor_data_store_free(SensorDataStore *store); - AvgWindows* avg_windows_parse(const gchar *spec, guint interval_ms); void avg_windows_free(AvgWindows *w); void avg_series_init(AvgSeries *s, const AvgWindows *w); diff --git a/src/zenmonitor-cli.c b/src/zenmonitor-cli.c index 43e688d..0706296 100644 --- a/src/zenmonitor-cli.c +++ b/src/zenmonitor-cli.c @@ -22,12 +22,13 @@ static gint refresh_in_place = 0; static gint output_once = 0; static gint daemon_mode = 0; -static SensorDataStore *store; +static FILE *csv = NULL; // streaming CSV log (--file), NULL when off +static guint n_selected = 0; // sensors passing the --sensors filter static volatile sig_atomic_t stop_requested = 0; static GOptionEntry options[] = { {"file", 'f', G_OPTION_FLAG_NONE, G_OPTION_ARG_STRING, &file, - "Output collected readings to a CSV file on exit", "FILE"}, + "Append readings to a CSV file (one row per refresh, crash-safe)", "FILE"}, {"delay", 'd', G_OPTION_FLAG_NONE, G_OPTION_ARG_DOUBLE, &delay, "Interval between refreshes in seconds (default 0.5)", "SECONDS"}, {"coreid", 'c', 0, G_OPTION_ARG_NONE, &display_coreid, @@ -67,47 +68,13 @@ static SensorSource sensor_sources[] = { } }; -// Async-signal-safe: just request a clean shutdown. The actual CSV flush, -// snapshot removal, and ncurses teardown happen back in the main loop. +// Async-signal-safe: just request a clean shutdown. The ncurses teardown, +// CSV close, and daemon snapshot removal happen back in the main loop. static void request_stop(int signum) { (void)signum; stop_requested = 1; } -static void flush_to_csv(const gchar *path) { - FILE *csv; - guint sensors, samples, s, t; - - csv = fopen(path, "w"); - if (!csv) { - fprintf(stderr, "zenmonitor-cli: cannot open '%s': %s\n", - path, g_strerror(errno)); - return; - } - - sensors = store->labels->len; - samples = store->time->len; - - fprintf(csv, "time(epoch)"); - for (s = 0; s < sensors; s++) - fprintf(csv, ",%s", (gchar *)g_ptr_array_index(store->labels, s)); - fprintf(csv, "\n"); - - for (t = 0; t < samples; t++) { - struct timespec ts = g_array_index(store->time, struct timespec, t); - fprintf(csv, "%ld.%.9ld", (long)ts.tv_sec, (long)ts.tv_nsec); - - for (s = 0; s < sensors; s++) { - GArray *data = g_ptr_array_index(store->data, s); - float value = (t < data->len) ? g_array_index(data, float, t) : 0.0f; - fprintf(csv, ",%f", value); - } - fprintf(csv, "\n"); - } - - fclose(csv); -} - #define sensor_selected(label) str_filter_match(sensor_filter, (label)) static void init_sensors(void) { @@ -123,27 +90,62 @@ static void init_sensors(void) { sensor = source->sensors; while (sensor) { data = (SensorInit *)sensor->data; - if (sensor_selected(data->label)) { - sensor_data_store_add_entry(store, data->label); + if (sensor_selected(data->label)) selected++; - } sensor = sensor->next; } // Skip the whole source at update time when nothing matched, // avoiding its per-tick sensor reads (e.g. the MSR preads). source->enabled = (selected > 0); + n_selected += selected; } } } } +// Open the CSV log and write the header. Rows are appended one per refresh in +// update_data() and flushed immediately, so the file is valid mid-run +// (tail -f works) and survives a crash up to the last row - unlike the old +// dump-at-exit design, which also held every sample in memory. +static gboolean csv_open(const gchar *path) { + SensorSource *source; + GSList *node; + const SensorInit *data; + + csv = fopen(path, "w"); + if (!csv) { + fprintf(stderr, "zenmonitor-cli: cannot open '%s': %s\n", + path, g_strerror(errno)); + return FALSE; + } + + // Column order matches the iteration order used in update_data(). + fprintf(csv, "time(epoch)"); + for (source = sensor_sources; source->drv; source++) { + if (!source->enabled) + continue; + for (node = source->sensors; node; node = node->next) { + data = (SensorInit *)node->data; + if (sensor_selected(data->label)) + fprintf(csv, ",%s", data->label); + } + } + fprintf(csv, "\n"); + fflush(csv); + return TRUE; +} + static void update_data(void) { SensorSource *source; GSList *node; const SensorInit *sensorData; int row = 1; // ncurses uses 1-based row indexing - sensor_data_store_keep_time(store); + if (csv) { + struct timespec ts; + timespec_get(&ts, TIME_UTC); + fprintf(csv, "%ld.%.9ld", (long)ts.tv_sec, (long)ts.tv_nsec); + } for (source = sensor_sources; source->drv; source++) { if (!source->enabled) @@ -160,7 +162,8 @@ static void update_data(void) { node = node->next; continue; } - sensor_data_store_add_data(store, sensorData->label, *sensorData->value); + if (csv) + fprintf(csv, ",%f", *sensorData->value); if (refresh_in_place) { mvprintw(row++, 0, "%s\t%f", sensorData->label, *sensorData->value); @@ -171,6 +174,11 @@ static void update_data(void) { } } + if (csv) { + fprintf(csv, "\n"); + fflush(csv); + } + if (refresh_in_place) refresh(); else @@ -274,7 +282,7 @@ static int run_daemon(void) { interval_ms = (guint)(delay * 1000.0 + 0.5); avg = avg_windows_parse(average_spec, interval_ms); - n_sensors = store->labels->len; + n_sensors = n_selected; series = g_new0(AvgSeries, n_sensors ? n_sensors : 1); for (i = 0; i < n_sensors; i++) avg_series_init(&series[i], avg); @@ -323,19 +331,24 @@ int main(int argc, char *argv[]) { } // Handle Ctrl-C/termination ourselves so ncurses is torn down, the CSV is - // written, and the daemon snapshot is cleaned up. + // closed, and the daemon snapshot is cleaned up. signal(SIGINT, request_stop); signal(SIGTERM, request_stop); - store = sensor_data_store_new(); init_sensors(); if (daemon_mode) { + if (write_csv) + fprintf(stderr, "zenmonitor-cli: --file is ignored in --daemon mode\n"); ret = run_daemon(); - sensor_data_store_free(store); return ret; } + // Fail fast if the log can't be opened, instead of sampling for hours and + // discovering it at exit. + if (write_csv && !csv_open(file)) + return EXIT_FAILURE; + if (refresh_in_place) { initscr(); curs_set(0); @@ -346,9 +359,8 @@ int main(int argc, char *argv[]) { if (refresh_in_place) endwin(); - if (write_csv) - flush_to_csv(file); + if (csv) + fclose(csv); - sensor_data_store_free(store); return EXIT_SUCCESS; } diff --git a/src/zenmonitor-lib.c b/src/zenmonitor-lib.c index eda1d86..22dbe49 100644 --- a/src/zenmonitor-lib.c +++ b/src/zenmonitor-lib.c @@ -1,7 +1,6 @@ #define _GNU_SOURCE /* for strcasestr */ #include #include -#include #include "zenmonitor.h" #define AMD_STRING "AuthenticAMD" @@ -90,70 +89,6 @@ void sensor_init_free(SensorInit *s) { } } -// Time-series store used by the CLI to accumulate readings for CSV export. -// labels: GPtrArray of gchar* sensor names (borrowed, owned by the sensors). -// data: GPtrArray of GArray, one series per label. -// time: GArray, one timestamp per sample row. -SensorDataStore *sensor_data_store_new(void) { - SensorDataStore *ret; - - ret = g_new0(SensorDataStore, 1); - ret->labels = g_ptr_array_new(); - ret->data = g_ptr_array_new(); - ret->time = g_array_new(FALSE, TRUE, sizeof(struct timespec)); - - return ret; -} - -void sensor_data_store_add_entry(SensorDataStore *store, gchar *entry) { - GArray *data; - data = g_array_new(TRUE, TRUE, sizeof(float)); - - g_ptr_array_add(store->labels, entry); - g_ptr_array_add(store->data, data); -} - -gint sensor_data_store_drop_entry(SensorDataStore *store, gchar *entry) { - guint index = 0; - if (!g_ptr_array_find(store->labels, entry, &index)) - return 1; - - g_ptr_array_remove_index(store->labels, index); - g_ptr_array_remove_index(store->data, index); - - return 0; -} - -void sensor_data_store_keep_time(SensorDataStore *store) { - struct timespec ts; - timespec_get(&ts, TIME_UTC); - g_array_append_val(store->time, ts); -} - -gint sensor_data_store_add_data(SensorDataStore *store, gchar *entry, float value) { - guint index = 0; - if (!g_ptr_array_find(store->labels, entry, &index)) - return 1; - - GArray *data = g_ptr_array_index(store->data, index); - g_array_append_val(data, value); - - return 0; -} - -void sensor_data_store_free(SensorDataStore *store) { - if (store) { - guint i; - for (i = 0; i < store->data->len; i++) - g_array_free(g_ptr_array_index(store->data, i), TRUE); - - g_array_free(store->time, TRUE); - g_ptr_array_free(store->labels, TRUE); - g_ptr_array_free(store->data, TRUE); - g_free(store); - } -} - // ---- Rolling averages ----------------------------------------------------- // A window given in seconds is converted to a sample count using the caller's // sampling interval, so the same "5m" means 5 minutes regardless of how often