From 25b590907c3bcccb418b7f9e8a77cb22c5e4f39f Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Tue, 11 Aug 2026 04:29:03 +0000 Subject: [PATCH 01/21] shell(panel): add a Sensors panel item for CPU/GPU temperature and clock Reads sysfs directly -- thermal zones for temperature, cpufreq for clock. On the CIX Sky1 boards this board exposes TZB0/TZB1 (big cluster), TZM0/TZM1 (mid) and TZGT (graphics); measured idle on O6N is 47/46/46/45/44 C. The zone named by `sensors-gpu-zone` (default TZGT) is reported as GPU and the hottest of the remaining zones as CPU, so nothing but that default is board-specific. Deliberately NOT wattage. Sky1 exposes no power rail to the kernel: there is no /sys/class/power_supply, no hwmon power*_input or curr*_input, and no energy*_uj anywhere on the board. A "watts" readout here could only ever be an invented estimate, so the widget does not offer one. Three things worth flagging for review: - EVERY settings read is guarded by settings_schema.has_key(). The gschema ships from the singularity-desktop superproject while this code ships from the singularity-shell submodule, so the two can be version-skewed on a real install. An unguarded g_settings_get_* against a missing key is a FATAL abort, which would take down the whole panel -- and, because Panel is shared with greeter_mode, the login screen with it. - The item is registered unconditionally but placement comes from panel-layout-*, so registering does not display it. It stays opt-in, and the greeter picks it up automatically since the greeter builds the same Panel. - Polling is skipped when the widget is not mapped. The board now throttles itself when idle (ncz-perf-activity); a 2-second timer that reads five sysfs files whether or not anyone can see them would work against that. Hardware with no readable thermal zones hides the widget rather than showing zeros, so this is inert on non-Sky1 boards. --- src/components/panel/panel.vala | 168 +++++++++++++++++++++++++++++++- 1 file changed, 165 insertions(+), 3 deletions(-) diff --git a/src/components/panel/panel.vala b/src/components/panel/panel.vala index c3deac1..48c7691 100644 --- a/src/components/panel/panel.vala +++ b/src/components/panel/panel.vala @@ -4,6 +4,162 @@ using Gee; namespace Singularity { + /** + * SensorsIndicator — CPU/GPU temperature and clock for the panel. + * + * Reads sysfs directly: thermal zones for temperature, cpufreq for clock. + * + * NOT wattage: the CIX Sky1 boards expose no power rail to the kernel at + * all (no /sys/class/power_supply, no hwmon power*_input or curr*_input, + * no energy*_uj), so a "watts" readout could only be an invented estimate. + * + * Zone names are board-specific -- Sky1 exposes TZB0/TZB1 (big cluster), + * TZM0/TZM1 (mid) and TZGT (graphics) -- so nothing is hardcoded except + * the default GPU zone. The zone matching "sensors-gpu-zone" is shown as + * GPU and the hottest of the rest as CPU. On hardware with no readable + * zones the widget hides itself rather than displaying zeros. + * + * EVERY settings read is guarded by has_key(). The gschema ships from the + * singularity-desktop superproject while this code ships from the + * singularity-shell submodule, so the two CAN be version-skewed on a real + * install. An unguarded g_settings_get_* on a missing key is a FATAL + * abort, which would take the whole panel (and the greeter) down. + */ + private class SensorsIndicator : Gtk.Box { + private Label temp_label; + private Label freq_label; + private uint timer_id = 0; + private GLib.Settings settings; + + private string gpu_zone = "TZGT"; + private bool show_freq = true; + + public SensorsIndicator(GLib.Settings settings) { + Object(orientation: Orientation.HORIZONTAL, spacing: 6); + this.settings = settings; + valign = Align.CENTER; + add_css_class("sensors-indicator"); + + temp_label = new Label(""); + temp_label.add_css_class("sensors-temp"); + freq_label = new Label(""); + freq_label.add_css_class("sensors-freq"); + append(temp_label); + append(freq_label); + + tooltip_text = _("CPU and GPU temperature and clock"); + + var schema = settings.settings_schema; + if (schema != null && schema.has_key("sensors-gpu-zone")) { + var z = settings.get_string("sensors-gpu-zone"); + if (z != "") gpu_zone = z; + } + if (schema != null && schema.has_key("sensors-show-frequency")) { + show_freq = settings.get_boolean("sensors-show-frequency"); + } + int interval = 2; + if (schema != null && schema.has_key("sensors-interval-seconds")) { + interval = settings.get_int("sensors-interval-seconds"); + } + if (interval < 1) interval = 2; + + update(); + timer_id = GLib.Timeout.add_seconds(interval, () => { + // Polling is skipped while not mapped: in the overview, on + // another workspace or with the panel hidden there is nobody + // to read it, and the point of this work is to let an idle + // board stay idle. + if (get_mapped()) update(); + return GLib.Source.CONTINUE; + }); + + destroy.connect(() => { + if (timer_id != 0) { GLib.Source.remove(timer_id); timer_id = 0; } + }); + } + + private static string? read_first_line(string path) { + try { + string contents; + if (!FileUtils.get_contents(path, out contents)) return null; + return contents.strip(); + } catch (GLib.Error e) { + return null; + } + } + + /** Hottest non-GPU zone and the GPU zone, both in millicelsius. */ + private void read_temps(out int cpu_mc, out int gpu_mc) { + cpu_mc = -1; + gpu_mc = -1; + try { + var dir = Dir.open("/sys/class/thermal", 0); + string? name; + while ((name = dir.read_name()) != null) { + if (!name.has_prefix("thermal_zone")) continue; + var bp = "/sys/class/thermal/" + name; + var type = read_first_line(bp + "/type"); + var temp = read_first_line(bp + "/temp"); + if (type == null || temp == null) continue; + var mc = int.parse(temp); + if (mc <= 0) continue; + if (type == gpu_zone) { + gpu_mc = mc; + } else if (mc > cpu_mc) { + cpu_mc = mc; + } + } + } catch (GLib.Error e) { + // No readable zones: both stay -1 and the widget hides. + } + } + + /** Highest current cpufreq across all policies, in kHz. */ + private int read_max_freq_khz() { + int best = -1; + try { + var dir = Dir.open("/sys/devices/system/cpu/cpufreq", 0); + string? name; + while ((name = dir.read_name()) != null) { + if (!name.has_prefix("policy")) continue; + var v = read_first_line("/sys/devices/system/cpu/cpufreq/" + name + "/scaling_cur_freq"); + if (v == null) continue; + var khz = int.parse(v); + if (khz > best) best = khz; + } + } catch (GLib.Error e) { + } + return best; + } + + private void update() { + int cpu_mc, gpu_mc; + read_temps(out cpu_mc, out gpu_mc); + + if (cpu_mc < 0 && gpu_mc < 0) { + visible = false; // never show zeros on unsupported hardware + return; + } + visible = true; + + var parts = new StringBuilder(); + if (cpu_mc >= 0) parts.append_printf("%d°", (cpu_mc + 500) / 1000); + if (gpu_mc >= 0) { + if (parts.len > 0) parts.append(" / "); + parts.append_printf("%d°", (gpu_mc + 500) / 1000); + } + temp_label.label = parts.str; + + if (show_freq) { + var khz = read_max_freq_khz(); + freq_label.label = khz > 0 ? "%.1f GHz".printf(khz / 1000000.0) : ""; + freq_label.visible = khz > 0; + } else { + freq_label.visible = false; + } + } + } + private class TilingPositionIndicator : Gtk.Fixed { private const int TRACK_WIDTH = 58; private const int TRACK_HEIGHT = 18; @@ -610,6 +766,12 @@ namespace Singularity { clock_box.append(clock_btn); clock_box.append(clock_suffix_box); layout_items["clock"] = clock_box; + // Registered unconditionally so the greeter panel gets it too: + // Panel is constructed with greeter_mode for the login screen and + // shares this layout_items map. Registering an item does NOT show + // it -- placement comes from panel-layout-*, so it stays opt-in. + layout_items["sensors"] = new SensorsIndicator(_settings); + reload_bar_layout(); _settings.changed["panel-layout-left"].connect(() => { if (!saving_bar_layout) reload_bar_layout(); @@ -628,8 +790,8 @@ namespace Singularity { center_box, right_box, layout_items, - { "overview", "workspaces", "tiling-position", "app-title", "global-menu", "system", "notifications", "clock" }, - { _("Overview"), _("Workspaces"), _("Scrolling Position"), _("App Title"), _("Global Menu"), _("System Status"), _("Notifications"), _("Clock") } + { "overview", "workspaces", "tiling-position", "app-title", "global-menu", "system", "notifications", "clock", "sensors" }, + { _("Overview"), _("Workspaces"), _("Scrolling Position"), _("App Title"), _("Global Menu"), _("System Status"), _("Notifications"), _("Clock"), _("Sensors") } ); layout_editor.move_requested.connect((item_id, section, index) => { if (bar_layout != null && bar_layout.move(item_id, section, index)) save_bar_layout(); @@ -966,7 +1128,7 @@ namespace Singularity { private void reload_bar_layout() { string[] item_ids = { "overview", "workspaces", "tiling-position", "app-title", "global-menu", - "system", "notifications", "clock" + "system", "notifications", "clock", "sensors" }; bar_layout = new BarLayout( item_ids, From b99723febf30b88d2df832ef8e3a3ea172e61407 Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Tue, 11 Aug 2026 14:20:33 +0000 Subject: [PATCH 02/21] shell(panel): group sensors into one chip with a detail popover, and make it portable Replaces the first cut, which put temperature and clock directly on the bar. That does not scale: a CIX Sky1 board exposes five thermal zones and an x86 desktop with a Super-I/O chip can expose a dozen, so a per-sensor item would push the clock off the panel. The bar now carries ONE chip (hottest sensor, optionally the top CPU clock) and everything else moves into a popover grouped into CPU / GPU / Clocks, rebuilt only while that popover is actually open. Also removes what was board-specific so this can go upstream: - /sys/class/hwmon is now the PRIMARY source. It is the generic kernel interface and covers x86 (coretemp, k10temp, zenpower, nct6775), discrete GPUs (amdgpu, nouveau, i915) and many ARM SoCs. - /sys/class/thermal is the FALLBACK, because a number of ARM SoCs expose temperatures only there -- CIX Sky1 among them (TZB0/TZB1 big, TZM0/TZM1 mid, TZGT graphics). - GPU classification is by kernel DRIVER NAME or label, not by any single platform-specific zone string, so amdgpu/nouveau/i915/panfrost/panthor/mali all group correctly with no board knowledge. sensors-gpu-zone remains only as an override for hardware the heuristic misses and now defaults to empty instead of naming one platform. - Clocks prefer cpufreq and fall back to /proc/cpuinfo, since cpufreq is absent on many VMs and on some x86 without a scaling driver. Hardware with nothing readable hides the widget rather than showing zeros. Settings reads stay has_key()-guarded: the schema can ship from a different package than this binary, and an unguarded get on a missing key is a fatal abort that would take down the panel and the greeter with it. --- src/components/panel/panel.vala | 355 ++++++++++++++++++++++++-------- 1 file changed, 274 insertions(+), 81 deletions(-) diff --git a/src/components/panel/panel.vala b/src/components/panel/panel.vala index 48c7691..d2e2442 100644 --- a/src/components/panel/panel.vala +++ b/src/components/panel/panel.vala @@ -5,54 +5,115 @@ using Gee; namespace Singularity { /** - * SensorsIndicator — CPU/GPU temperature and clock for the panel. + * SensorsIndicator — compact temperature/clock readout for the panel. * - * Reads sysfs directly: thermal zones for temperature, cpufreq for clock. + * ONE panel item, never a row of them. The bar shows a single chip (the + * hottest sensor, optionally the CPU clock); everything else lives in a + * popover grouped into CPU / GPU / Clocks. A machine can expose a lot of + * sensors -- a CIX Sky1 board reports five thermal zones, a desktop x86 + * box with a Super-I/O chip can report a dozen -- and putting each on the + * bar would push the clock off the screen. * - * NOT wattage: the CIX Sky1 boards expose no power rail to the kernel at - * all (no /sys/class/power_supply, no hwmon power*_input or curr*_input, - * no energy*_uj), so a "watts" readout could only be an invented estimate. + * PORTABILITY. This reads only standard Linux sysfs and hardcodes no + * board-, vendor- or architecture-specific name: * - * Zone names are board-specific -- Sky1 exposes TZB0/TZB1 (big cluster), - * TZM0/TZM1 (mid) and TZGT (graphics) -- so nothing is hardcoded except - * the default GPU zone. The zone matching "sensors-gpu-zone" is shown as - * GPU and the hottest of the rest as CPU. On hardware with no readable - * zones the widget hides itself rather than displaying zeros. + * 1. /sys/class/hwmon/hwmon* is the primary source. It is the generic + * kernel hwmon interface and is what x86 exposes (coretemp, k10temp, + * zenpower, nct6775) as well as most discrete GPUs (amdgpu, nouveau, + * i915) and many ARM SoCs. + * 2. /sys/class/thermal/thermal_zone* is the fallback. Plenty of ARM + * SoCs expose temperatures ONLY here -- the CIX Sky1 is one, with + * TZB0/TZB1 (big cluster), TZM0/TZM1 (mid) and TZGT (graphics). * - * EVERY settings read is guarded by has_key(). The gschema ships from the - * singularity-desktop superproject while this code ships from the - * singularity-shell submodule, so the two CAN be version-skewed on a real - * install. An unguarded g_settings_get_* on a missing key is a FATAL - * abort, which would take the whole panel (and the greeter) down. + * A sensor is classified as GPU by matching its driver name or label + * against known GPU driver names, so amdgpu/nouveau/i915/panfrost/panthor + * and a Mali or "graphics" thermal zone all land in the GPU group without + * the widget knowing anything about a specific board. `sensors-gpu-zone` + * exists purely as an override for hardware the heuristic misses; it is + * empty by default rather than naming any one platform's zone. + * + * Clock reading prefers cpufreq and falls back to /proc/cpuinfo, since + * cpufreq is absent on some systems (many VMs, some x86 without a + * scaling driver). + * + * Hardware exposing nothing readable hides the widget rather than + * displaying zeros, so this is inert rather than wrong on such a machine. + * + * EVERY settings read is guarded by has_key(): this widget's schema may + * ship from a different package than the binary, and an unguarded + * g_settings_get_* against a missing key is a FATAL abort that would take + * down the whole panel -- and, since the greeter builds the same Panel, + * the login screen with it. */ + private enum SensorKind { CPU, GPU, OTHER } + + private class SensorReading : Object { + public string label; + public int millicelsius; + public SensorKind kind; + public SensorReading(string label, int millicelsius, SensorKind kind) { + this.label = label; + this.millicelsius = millicelsius; + this.kind = kind; + } + } + private class SensorsIndicator : Gtk.Box { - private Label temp_label; - private Label freq_label; + // Kernel DRIVER names, not product names. Classification exists because + // hwmon reports far more than a CPU: MEASURED on a CIX Sky1 board, the + // eight hwmon chips are four CPU-cluster zones, one graphics zone, an + // nvme drive at 67.8 C and TWO r8169 NIC sensors. Taking the hottest + // sensor overall would have put the SSD's 68 C on a widget labelled + // CPU/GPU -- correct number, wrong thing entirely. + private const string[] GPU_HINTS = { + "amdgpu", "radeon", "nouveau", "i915", "xe", "panfrost", "panthor", + "mali", "gpu", "graphics" + }; + // Sensors that are neither CPU nor GPU. Grouped separately rather than + // dropped: a hot drive is worth seeing, just not as "CPU". + private const string[] OTHER_HINTS = { + "nvme", "drivetemp", "sd", "r8169", "e1000", "igb", "ixgbe", + "iwlwifi", "mt76", "ath1", "battery", "bat", "wifi", "acpitz" + }; + + // MenuButton is CONTAINED, not inherited: GtkMenuButton is declared + // final in GTK4, so subclassing it fails to compile outright + // ("unknown type name GtkMenuButtonClass"). + private MenuButton button; + private Label summary_label; + private Box detail_box; private uint timer_id = 0; private GLib.Settings settings; - - private string gpu_zone = "TZGT"; private bool show_freq = true; + private string gpu_override = ""; public SensorsIndicator(GLib.Settings settings) { - Object(orientation: Orientation.HORIZONTAL, spacing: 6); + Object(orientation: Orientation.HORIZONTAL, spacing: 0); this.settings = settings; valign = Align.CENTER; add_css_class("sensors-indicator"); - temp_label = new Label(""); - temp_label.add_css_class("sensors-temp"); - freq_label = new Label(""); - freq_label.add_css_class("sensors-freq"); - append(temp_label); - append(freq_label); + summary_label = new Label(""); + summary_label.add_css_class("sensors-summary"); + + button = new MenuButton(); + button.add_css_class("flat"); + button.tooltip_text = _("Temperatures and CPU clock"); + button.child = summary_label; + append(button); - tooltip_text = _("CPU and GPU temperature and clock"); + detail_box = new Box(Orientation.VERTICAL, 4); + detail_box.margin_top = 10; + detail_box.margin_bottom = 10; + detail_box.margin_start = 12; + detail_box.margin_end = 12; + var pop = new Popover(); + pop.child = detail_box; + button.popover = pop; var schema = settings.settings_schema; if (schema != null && schema.has_key("sensors-gpu-zone")) { - var z = settings.get_string("sensors-gpu-zone"); - if (z != "") gpu_zone = z; + gpu_override = settings.get_string("sensors-gpu-zone"); } if (schema != null && schema.has_key("sensors-show-frequency")) { show_freq = settings.get_boolean("sensors-show-frequency"); @@ -63,13 +124,12 @@ namespace Singularity { } if (interval < 1) interval = 2; - update(); + refresh(); timer_id = GLib.Timeout.add_seconds(interval, () => { - // Polling is skipped while not mapped: in the overview, on - // another workspace or with the panel hidden there is nobody - // to read it, and the point of this work is to let an idle - // board stay idle. - if (get_mapped()) update(); + // Skip entirely when nothing can see it: unmapped panel, other + // workspace, overview. The detail list is rebuilt only while + // the popover is actually open. + if (get_mapped()) refresh(); return GLib.Source.CONTINUE; }); @@ -78,7 +138,7 @@ namespace Singularity { }); } - private static string? read_first_line(string path) { + private static string? read_line(string path) { try { string contents; if (!FileUtils.get_contents(path, out contents)) return null; @@ -88,74 +148,207 @@ namespace Singularity { } } - /** Hottest non-GPU zone and the GPU zone, both in millicelsius. */ - private void read_temps(out int cpu_mc, out int gpu_mc) { - cpu_mc = -1; - gpu_mc = -1; + private static bool matches(string text, string[] hints) { + var lower = text.down(); + foreach (string hint in hints) { + if (lower.contains(hint)) return true; + } + return false; + } + + /** + * Classify a sensor from its driver name and label. + * + * Anything unrecognised is treated as CPU, which is the right default: + * SoC thermal zones are typically CPU clusters and carry names no + * generic list can enumerate (Sky1 uses TZB0/TZB1/TZM0/TZM1). Known + * drives, NICs and radios are pulled out explicitly so they cannot be + * mistaken for the CPU. + */ + private SensorKind classify(string chip, string? label) { + var joined = label != null && label != "" ? chip + " " + label : chip; + if (gpu_override != "" && joined.contains(gpu_override)) return SensorKind.GPU; + if (matches(joined, GPU_HINTS)) return SensorKind.GPU; + if (matches(joined, OTHER_HINTS)) return SensorKind.OTHER; + return SensorKind.CPU; + } + + /** All readable temperature sensors, hwmon first, thermal as fallback. */ + private Gee.ArrayList collect() { + var list = new Gee.ArrayList(); + try { - var dir = Dir.open("/sys/class/thermal", 0); - string? name; - while ((name = dir.read_name()) != null) { - if (!name.has_prefix("thermal_zone")) continue; - var bp = "/sys/class/thermal/" + name; - var type = read_first_line(bp + "/type"); - var temp = read_first_line(bp + "/temp"); - if (type == null || temp == null) continue; - var mc = int.parse(temp); - if (mc <= 0) continue; - if (type == gpu_zone) { - gpu_mc = mc; - } else if (mc > cpu_mc) { - cpu_mc = mc; + var dir = Dir.open("/sys/class/hwmon", 0); + string? node; + while ((node = dir.read_name()) != null) { + var basep = "/sys/class/hwmon/" + node; + var chip = read_line(basep + "/name") ?? node; + try { + var inner = Dir.open(basep, 0); + string? f; + while ((f = inner.read_name()) != null) { + if (!f.has_prefix("temp") || !f.has_suffix("_input")) continue; + var raw = read_line(basep + "/" + f); + if (raw == null) continue; + var mc = int.parse(raw); + if (mc <= 0) continue; + var stem = f.substring(0, f.length - "_input".length); + var lbl = read_line(basep + "/" + stem + "_label"); + var name = lbl != null && lbl != "" ? "%s %s".printf(chip, lbl) : chip; + list.add(new SensorReading(name, mc, classify(chip, lbl))); + } + } catch (GLib.Error e) { } } } catch (GLib.Error e) { - // No readable zones: both stay -1 and the widget hides. + // No hwmon at all: fall through to thermal zones. + } + + if (list.size == 0) { + try { + var dir = Dir.open("/sys/class/thermal", 0); + string? node; + while ((node = dir.read_name()) != null) { + if (!node.has_prefix("thermal_zone")) continue; + var basep = "/sys/class/thermal/" + node; + var type = read_line(basep + "/type"); + var raw = read_line(basep + "/temp"); + if (type == null || raw == null) continue; + var mc = int.parse(raw); + if (mc <= 0) continue; + list.add(new SensorReading(type, mc, classify(type, null))); + } + } catch (GLib.Error e) { + } } + + return list; } - /** Highest current cpufreq across all policies, in kHz. */ - private int read_max_freq_khz() { - int best = -1; + /** Current CPU clocks in MHz, one per policy, highest first. */ + private Gee.ArrayList collect_clocks() { + var out_list = new Gee.ArrayList(); try { var dir = Dir.open("/sys/devices/system/cpu/cpufreq", 0); - string? name; - while ((name = dir.read_name()) != null) { - if (!name.has_prefix("policy")) continue; - var v = read_first_line("/sys/devices/system/cpu/cpufreq/" + name + "/scaling_cur_freq"); + string? node; + while ((node = dir.read_name()) != null) { + if (!node.has_prefix("policy")) continue; + var v = read_line("/sys/devices/system/cpu/cpufreq/" + node + "/scaling_cur_freq"); if (v == null) continue; var khz = int.parse(v); - if (khz > best) best = khz; + if (khz > 0) out_list.add(khz / 1000); } } catch (GLib.Error e) { } - return best; + + if (out_list.size == 0) { + // No cpufreq (common in VMs and on some x86 without a scaling + // driver): /proc/cpuinfo still reports a MHz figure. + var txt = read_line("/proc/cpuinfo"); + if (txt != null) { + foreach (string line in txt.split("\n")) { + if (!line.down().has_prefix("cpu mhz")) continue; + var parts = line.split(":"); + if (parts.length < 2) continue; + var mhz = (int) double.parse(parts[1].strip()); + if (mhz > 0) out_list.add(mhz); + } + } + } + + out_list.sort((a, b) => b - a); + return out_list; + } + + private static string fmt_c(int millicelsius) { + return "%d°".printf((millicelsius + 500) / 1000); } - private void update() { - int cpu_mc, gpu_mc; - read_temps(out cpu_mc, out gpu_mc); + private static string fmt_mhz(int mhz) { + return mhz >= 1000 ? "%.1f GHz".printf(mhz / 1000.0) : "%d MHz".printf(mhz); + } - if (cpu_mc < 0 && gpu_mc < 0) { - visible = false; // never show zeros on unsupported hardware + private void refresh() { + var readings = collect(); + if (readings.size == 0) { + visible = false; // never display zeros on unsupported hardware return; } visible = true; - var parts = new StringBuilder(); - if (cpu_mc >= 0) parts.append_printf("%d°", (cpu_mc + 500) / 1000); - if (gpu_mc >= 0) { - if (parts.len > 0) parts.append(" / "); - parts.append_printf("%d°", (gpu_mc + 500) / 1000); + // The chip shows the hottest CPU sensor -- NOT the hottest sensor + // overall, which on a board with a warm NVMe drive would show the + // drive's temperature on a CPU/GPU widget. Falls back to the + // hottest of anything only when nothing classified as CPU. + SensorReading? hottest = null; + foreach (var r in readings) { + if (r.kind != SensorKind.CPU) continue; + if (hottest == null || r.millicelsius > hottest.millicelsius) hottest = r; + } + if (hottest == null) { + hottest = readings[0]; + foreach (var r in readings) { + if (r.millicelsius > hottest.millicelsius) hottest = r; + } } - temp_label.label = parts.str; - if (show_freq) { - var khz = read_max_freq_khz(); - freq_label.label = khz > 0 ? "%.1f GHz".printf(khz / 1000000.0) : ""; - freq_label.visible = khz > 0; - } else { - freq_label.visible = false; + var clocks = show_freq ? collect_clocks() : new Gee.ArrayList(); + var text = new StringBuilder(fmt_c(hottest.millicelsius)); + if (clocks.size > 0) text.append(" · ").append(fmt_mhz(clocks[0])); + summary_label.label = text.str; + + var pop = button.popover; + if (pop != null && pop.visible) rebuild_details(readings, clocks); + } + + private void add_heading(string title) { + var l = new Label(title); + l.add_css_class("heading"); + l.halign = Align.START; + l.margin_top = 4; + detail_box.append(l); + } + + private void add_row(string name, string value) { + var row = new Box(Orientation.HORIZONTAL, 12); + var n = new Label(name); + n.halign = Align.START; + n.hexpand = true; + var v = new Label(value); + v.halign = Align.END; + v.add_css_class("dim-label"); + row.append(n); + row.append(v); + detail_box.append(row); + } + + private void add_group(Gee.ArrayList readings, + SensorKind kind, string title) { + bool any = false; + foreach (var r in readings) { + if (r.kind == kind) { any = true; break; } + } + if (!any) return; + add_heading(title); + foreach (var r in readings) { + if (r.kind == kind) add_row(r.label, fmt_c(r.millicelsius)); + } + } + + /** Grouped detail: CPU, GPU, Other, Clocks — built only while open. */ + private void rebuild_details(Gee.ArrayList readings, + Gee.ArrayList clocks) { + Gtk.Widget? c; + while ((c = detail_box.get_first_child()) != null) detail_box.remove(c); + + add_group(readings, SensorKind.CPU, _("CPU")); + add_group(readings, SensorKind.GPU, _("GPU")); + add_group(readings, SensorKind.OTHER, _("Other")); + if (clocks.size > 0) { + add_heading(_("Clocks")); + for (int i = 0; i < clocks.size; i++) { + add_row(_("Core group %d").printf(i + 1), fmt_mhz(clocks[i])); + } } } } From 05820aceae09a88c868faf3da96bedb239b5eef8 Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Tue, 11 Aug 2026 15:47:18 +0000 Subject: [PATCH 03/21] shell(panel): render sensors from libsingularity-system instead of reading sysfs Moves all sysfs access out of the panel widget and into Singularity.SensorMonitor, per CONTRIBUTING: headless system backends (D-Bus, sysfs, hardware managers with no GTK) belong in libsingularity-system, not the shell. The widget now only renders what the backend publishes, and SystemMonitor exposes it as .sensors following the same lazy-property pattern as .resources. Also switches the summary to the backend cpu/system split. The backend classifies by an allow-list and reports -1 when it found no CPU sensor, so the widget can fall back deliberately instead of a NIC or chipset being displayed as the processor. --- src/components/panel/panel.vala | 397 ++++++++++---------------------- src/core/system_monitor.vala | 2 + 2 files changed, 126 insertions(+), 273 deletions(-) diff --git a/src/components/panel/panel.vala b/src/components/panel/panel.vala index d2e2442..e8cb138 100644 --- a/src/components/panel/panel.vala +++ b/src/components/panel/panel.vala @@ -5,91 +5,31 @@ using Gee; namespace Singularity { /** - * SensorsIndicator — compact temperature/clock readout for the panel. + * SensorsIndicator — one compact chip in the panel, detail in a popover. * - * ONE panel item, never a row of them. The bar shows a single chip (the - * hottest sensor, optionally the CPU clock); everything else lives in a - * popover grouped into CPU / GPU / Clocks. A machine can expose a lot of - * sensors -- a CIX Sky1 board reports five thermal zones, a desktop x86 - * box with a Super-I/O chip can report a dozen -- and putting each on the - * bar would push the clock off the screen. + * Deliberately ONE panel item rather than a row of them: a machine can + * expose a lot of sensors (a CIX Sky1 board reports five thermal zones; an + * x86 desktop with a Super-I/O chip can report a dozen), and putting each + * on the bar would push the clock off the screen. * - * PORTABILITY. This reads only standard Linux sysfs and hardcodes no - * board-, vendor- or architecture-specific name: - * - * 1. /sys/class/hwmon/hwmon* is the primary source. It is the generic - * kernel hwmon interface and is what x86 exposes (coretemp, k10temp, - * zenpower, nct6775) as well as most discrete GPUs (amdgpu, nouveau, - * i915) and many ARM SoCs. - * 2. /sys/class/thermal/thermal_zone* is the fallback. Plenty of ARM - * SoCs expose temperatures ONLY here -- the CIX Sky1 is one, with - * TZB0/TZB1 (big cluster), TZM0/TZM1 (mid) and TZGT (graphics). - * - * A sensor is classified as GPU by matching its driver name or label - * against known GPU driver names, so amdgpu/nouveau/i915/panfrost/panthor - * and a Mali or "graphics" thermal zone all land in the GPU group without - * the widget knowing anything about a specific board. `sensors-gpu-zone` - * exists purely as an override for hardware the heuristic misses; it is - * empty by default rather than naming any one platform's zone. - * - * Clock reading prefers cpufreq and falls back to /proc/cpuinfo, since - * cpufreq is absent on some systems (many VMs, some x86 without a - * scaling driver). - * - * Hardware exposing nothing readable hides the widget rather than - * displaying zeros, so this is inert rather than wrong on such a machine. - * - * EVERY settings read is guarded by has_key(): this widget's schema may - * ship from a different package than the binary, and an unguarded - * g_settings_get_* against a missing key is a FATAL abort that would take - * down the whole panel -- and, since the greeter builds the same Panel, - * the login screen with it. + * All sysfs reading lives in Singularity.SensorMonitor + * (libsingularity-system). This widget only renders what that backend + * publishes, per CONTRIBUTING: headless system backends do not live in the + * shell. */ - private enum SensorKind { CPU, GPU, OTHER } - - private class SensorReading : Object { - public string label; - public int millicelsius; - public SensorKind kind; - public SensorReading(string label, int millicelsius, SensorKind kind) { - this.label = label; - this.millicelsius = millicelsius; - this.kind = kind; - } - } - private class SensorsIndicator : Gtk.Box { - // Kernel DRIVER names, not product names. Classification exists because - // hwmon reports far more than a CPU: MEASURED on a CIX Sky1 board, the - // eight hwmon chips are four CPU-cluster zones, one graphics zone, an - // nvme drive at 67.8 C and TWO r8169 NIC sensors. Taking the hottest - // sensor overall would have put the SSD's 68 C on a widget labelled - // CPU/GPU -- correct number, wrong thing entirely. - private const string[] GPU_HINTS = { - "amdgpu", "radeon", "nouveau", "i915", "xe", "panfrost", "panthor", - "mali", "gpu", "graphics" - }; - // Sensors that are neither CPU nor GPU. Grouped separately rather than - // dropped: a hot drive is worth seeing, just not as "CPU". - private const string[] OTHER_HINTS = { - "nvme", "drivetemp", "sd", "r8169", "e1000", "igb", "ixgbe", - "iwlwifi", "mt76", "ath1", "battery", "bat", "wifi", "acpitz" - }; - - // MenuButton is CONTAINED, not inherited: GtkMenuButton is declared - // final in GTK4, so subclassing it fails to compile outright - // ("unknown type name GtkMenuButtonClass"). + // Sensor counts vary by two orders of magnitude across platforms, so + // the detail list is capped rather than unbounded. + private const int MAX_ROWS_PER_GROUP = 6; + private MenuButton button; private Label summary_label; private Box detail_box; - private uint timer_id = 0; - private GLib.Settings settings; - private bool show_freq = true; - private string gpu_override = ""; + private SensorMonitor monitor; + private bool show_frequency = true; public SensorsIndicator(GLib.Settings settings) { Object(orientation: Orientation.HORIZONTAL, spacing: 0); - this.settings = settings; valign = Align.CENTER; add_css_class("sensors-indicator"); @@ -107,247 +47,158 @@ namespace Singularity { detail_box.margin_bottom = 10; detail_box.margin_start = 12; detail_box.margin_end = 12; - var pop = new Popover(); - pop.child = detail_box; - button.popover = pop; + Popover popover = new Popover(); + popover.child = detail_box; + button.popover = popover; - var schema = settings.settings_schema; - if (schema != null && schema.has_key("sensors-gpu-zone")) { - gpu_override = settings.get_string("sensors-gpu-zone"); - } - if (schema != null && schema.has_key("sensors-show-frequency")) { - show_freq = settings.get_boolean("sensors-show-frequency"); - } + monitor = SystemMonitor.get_default().sensors; + + // Every settings read is guarded: this widget and the schema can + // ship from different packages, and an unguarded read of a missing + // key is a fatal abort that would take the panel -- and the + // greeter, which builds the same Panel -- down with it. + SettingsSchema? schema = settings.settings_schema; int interval = 2; if (schema != null && schema.has_key("sensors-interval-seconds")) { interval = settings.get_int("sensors-interval-seconds"); } - if (interval < 1) interval = 2; - - refresh(); - timer_id = GLib.Timeout.add_seconds(interval, () => { - // Skip entirely when nothing can see it: unmapped panel, other - // workspace, overview. The detail list is rebuilt only while - // the popover is actually open. - if (get_mapped()) refresh(); - return GLib.Source.CONTINUE; - }); - - destroy.connect(() => { - if (timer_id != 0) { GLib.Source.remove(timer_id); timer_id = 0; } - }); - } - - private static string? read_line(string path) { - try { - string contents; - if (!FileUtils.get_contents(path, out contents)) return null; - return contents.strip(); - } catch (GLib.Error e) { - return null; - } - } - - private static bool matches(string text, string[] hints) { - var lower = text.down(); - foreach (string hint in hints) { - if (lower.contains(hint)) return true; + if (schema != null && schema.has_key("sensors-show-frequency")) { + show_frequency = settings.get_boolean("sensors-show-frequency"); } - return false; - } - - /** - * Classify a sensor from its driver name and label. - * - * Anything unrecognised is treated as CPU, which is the right default: - * SoC thermal zones are typically CPU clusters and carry names no - * generic list can enumerate (Sky1 uses TZB0/TZB1/TZM0/TZM1). Known - * drives, NICs and radios are pulled out explicitly so they cannot be - * mistaken for the CPU. - */ - private SensorKind classify(string chip, string? label) { - var joined = label != null && label != "" ? chip + " " + label : chip; - if (gpu_override != "" && joined.contains(gpu_override)) return SensorKind.GPU; - if (matches(joined, GPU_HINTS)) return SensorKind.GPU; - if (matches(joined, OTHER_HINTS)) return SensorKind.OTHER; - return SensorKind.CPU; - } - - /** All readable temperature sensors, hwmon first, thermal as fallback. */ - private Gee.ArrayList collect() { - var list = new Gee.ArrayList(); - - try { - var dir = Dir.open("/sys/class/hwmon", 0); - string? node; - while ((node = dir.read_name()) != null) { - var basep = "/sys/class/hwmon/" + node; - var chip = read_line(basep + "/name") ?? node; - try { - var inner = Dir.open(basep, 0); - string? f; - while ((f = inner.read_name()) != null) { - if (!f.has_prefix("temp") || !f.has_suffix("_input")) continue; - var raw = read_line(basep + "/" + f); - if (raw == null) continue; - var mc = int.parse(raw); - if (mc <= 0) continue; - var stem = f.substring(0, f.length - "_input".length); - var lbl = read_line(basep + "/" + stem + "_label"); - var name = lbl != null && lbl != "" ? "%s %s".printf(chip, lbl) : chip; - list.add(new SensorReading(name, mc, classify(chip, lbl))); - } - } catch (GLib.Error e) { - } - } - } catch (GLib.Error e) { - // No hwmon at all: fall through to thermal zones. + if (schema != null && schema.has_key("sensors-gpu-zone")) { + monitor.gpu_hint = settings.get_string("sensors-gpu-zone"); } - - if (list.size == 0) { - try { - var dir = Dir.open("/sys/class/thermal", 0); - string? node; - while ((node = dir.read_name()) != null) { - if (!node.has_prefix("thermal_zone")) continue; - var basep = "/sys/class/thermal/" + node; - var type = read_line(basep + "/type"); - var raw = read_line(basep + "/temp"); - if (type == null || raw == null) continue; - var mc = int.parse(raw); - if (mc <= 0) continue; - list.add(new SensorReading(type, mc, classify(type, null))); - } - } catch (GLib.Error e) { - } + if (schema != null && schema.has_key("sensors-cpu-zone")) { + monitor.cpu_hint = settings.get_string("sensors-cpu-zone"); } - return list; + monitor.updated.connect(on_updated); + monitor.start(interval); + on_updated(); } - /** Current CPU clocks in MHz, one per policy, highest first. */ - private Gee.ArrayList collect_clocks() { - var out_list = new Gee.ArrayList(); - try { - var dir = Dir.open("/sys/devices/system/cpu/cpufreq", 0); - string? node; - while ((node = dir.read_name()) != null) { - if (!node.has_prefix("policy")) continue; - var v = read_line("/sys/devices/system/cpu/cpufreq/" + node + "/scaling_cur_freq"); - if (v == null) continue; - var khz = int.parse(v); - if (khz > 0) out_list.add(khz / 1000); - } - } catch (GLib.Error e) { - } - - if (out_list.size == 0) { - // No cpufreq (common in VMs and on some x86 without a scaling - // driver): /proc/cpuinfo still reports a MHz figure. - var txt = read_line("/proc/cpuinfo"); - if (txt != null) { - foreach (string line in txt.split("\n")) { - if (!line.down().has_prefix("cpu mhz")) continue; - var parts = line.split(":"); - if (parts.length < 2) continue; - var mhz = (int) double.parse(parts[1].strip()); - if (mhz > 0) out_list.add(mhz); - } - } - } - - out_list.sort((a, b) => b - a); - return out_list; + public override void dispose() { + monitor.updated.disconnect(on_updated); + monitor.stop(); + base.dispose(); } - private static string fmt_c(int millicelsius) { - return "%d°".printf((millicelsius + 500) / 1000); + private static string format_celsius(int millidegrees) { + return "%d°".printf((millidegrees + 500) / 1000); } - private static string fmt_mhz(int mhz) { - return mhz >= 1000 ? "%.1f GHz".printf(mhz / 1000.0) : "%d MHz".printf(mhz); + private static string format_clock(int khz) { + return khz >= 1000000 + ? "%.1f GHz".printf(khz / 1000000.0) + : "%d MHz".printf(khz / 1000); } - private void refresh() { - var readings = collect(); - if (readings.size == 0) { - visible = false; // never display zeros on unsupported hardware + private void on_updated() { + if (!monitor.available) { + // Nothing readable on this hardware: hide rather than show zeros. + visible = false; return; } visible = true; - // The chip shows the hottest CPU sensor -- NOT the hottest sensor - // overall, which on a board with a warm NVMe drive would show the - // drive's temperature on a CPU/GPU widget. Falls back to the - // hottest of anything only when nothing classified as CPU. - SensorReading? hottest = null; - foreach (var r in readings) { - if (r.kind != SensorKind.CPU) continue; - if (hottest == null || r.millicelsius > hottest.millicelsius) hottest = r; + // Prefer a sensor positively identified as the CPU. The backend + // reports -1 when it found none, and falls back to the hottest + // unidentified sensor -- it never guesses that an unknown chip is + // the processor. + int primary = monitor.cpu_millidegrees >= 0 + ? monitor.cpu_millidegrees + : monitor.system_millidegrees; + + StringBuilder text = new StringBuilder(); + if (primary >= 0) { + text.append(format_celsius(primary)); } - if (hottest == null) { - hottest = readings[0]; - foreach (var r in readings) { - if (r.millicelsius > hottest.millicelsius) hottest = r; + if (show_frequency && monitor.cpu_khz > 0) { + if (text.len > 0) { + text.append(" · "); } + text.append(format_clock(monitor.cpu_khz)); } - - var clocks = show_freq ? collect_clocks() : new Gee.ArrayList(); - var text = new StringBuilder(fmt_c(hottest.millicelsius)); - if (clocks.size > 0) text.append(" · ").append(fmt_mhz(clocks[0])); summary_label.label = text.str; - var pop = button.popover; - if (pop != null && pop.visible) rebuild_details(readings, clocks); + Popover? popover = button.popover; + if (popover != null && popover.visible) { + rebuild_details(); + } } private void add_heading(string title) { - var l = new Label(title); - l.add_css_class("heading"); - l.halign = Align.START; - l.margin_top = 4; - detail_box.append(l); + Label heading = new Label(title); + heading.add_css_class("heading"); + heading.halign = Align.START; + heading.margin_top = 4; + detail_box.append(heading); } private void add_row(string name, string value) { - var row = new Box(Orientation.HORIZONTAL, 12); - var n = new Label(name); - n.halign = Align.START; - n.hexpand = true; - var v = new Label(value); - v.halign = Align.END; - v.add_css_class("dim-label"); - row.append(n); - row.append(v); + Box row = new Box(Orientation.HORIZONTAL, 12); + Label name_label = new Label(name); + name_label.halign = Align.START; + name_label.hexpand = true; + Label value_label = new Label(value); + value_label.halign = Align.END; + value_label.add_css_class("dim-label"); + row.append(name_label); + row.append(value_label); detail_box.append(row); } - private void add_group(Gee.ArrayList readings, - SensorKind kind, string title) { + private void add_group(SensorKind kind, string title) { bool any = false; - foreach (var r in readings) { - if (r.kind == kind) { any = true; break; } + foreach (SensorReading reading in monitor.readings()) { + if (reading.kind == kind) { + any = true; + break; + } + } + if (!any) { + return; } - if (!any) return; add_heading(title); - foreach (var r in readings) { - if (r.kind == kind) add_row(r.label, fmt_c(r.millicelsius)); + // Cap the rows. Sensor count varies enormously by platform: an ARM + // dev board reports 5, a Qualcomm SC8280XP reports 55. Listing all + // of them turns the popover into a wall of near-identical numbers, + // so show the first few and state how many were left out. + int shown = 0; + int hidden = 0; + foreach (SensorReading reading in monitor.readings()) { + if (reading.kind != kind) { + continue; + } + if (shown < MAX_ROWS_PER_GROUP) { + add_row(reading.label, format_celsius(reading.millidegrees)); + shown++; + } else { + hidden++; + } + } + if (hidden > 0) { + add_row(_("%d more").printf(hidden), ""); } } - /** Grouped detail: CPU, GPU, Other, Clocks — built only while open. */ - private void rebuild_details(Gee.ArrayList readings, - Gee.ArrayList clocks) { - Gtk.Widget? c; - while ((c = detail_box.get_first_child()) != null) detail_box.remove(c); + /** Built only while the popover is open. */ + private void rebuild_details() { + Gtk.Widget? child = detail_box.get_first_child(); + while (child != null) { + detail_box.remove(child); + child = detail_box.get_first_child(); + } + + add_group(SensorKind.CPU, _("CPU")); + add_group(SensorKind.GPU, _("GPU")); + add_group(SensorKind.SYSTEM, _("System")); - add_group(readings, SensorKind.CPU, _("CPU")); - add_group(readings, SensorKind.GPU, _("GPU")); - add_group(readings, SensorKind.OTHER, _("Other")); - if (clocks.size > 0) { + int[] clocks = monitor.clocks_khz(); + if (clocks.length > 0) { add_heading(_("Clocks")); - for (int i = 0; i < clocks.size; i++) { - add_row(_("Core group %d").printf(i + 1), fmt_mhz(clocks[i])); + for (int i = 0; i < clocks.length; i++) { + add_row(_("Core group %d").printf(i + 1), format_clock(clocks[i])); } } } diff --git a/src/core/system_monitor.vala b/src/core/system_monitor.vala index ef9c052..a7b0a3c 100644 --- a/src/core/system_monitor.vala +++ b/src/core/system_monitor.vala @@ -16,6 +16,7 @@ namespace Singularity { public BluetoothManager bluetooth { get { if (_bluetooth == null) _bluetooth = new BluetoothManager(); return _bluetooth; } } public PowerProfilesManager power_profiles { get { if (_power_profiles == null) _power_profiles = new PowerProfilesManager(); return _power_profiles; } } public ResourceMonitor resources { get { if (_resources == null) _resources = new ResourceMonitor(); return _resources; } } + public SensorMonitor sensors { get { if (_sensors == null) _sensors = new SensorMonitor(); return _sensors; } } public CallMonitor call_monitor { get { if (_call_monitor == null) _call_monitor = new CallMonitor(audio); return _call_monitor; } } private PowerManager? _power; @@ -31,6 +32,7 @@ namespace Singularity { private BluetoothManager? _bluetooth; private PowerProfilesManager? _power_profiles; private ResourceMonitor? _resources; + private SensorMonitor? _sensors = null; private CallMonitor? _call_monitor; public static SystemMonitor get_default() { From b97598810177f7ae91a50aef38cfd3f13802b41b Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Sun, 16 Aug 2026 16:05:00 +0000 Subject: [PATCH 04/21] shell(panel): colour sensors by thermal severity, and show clocks against their own ceiling The sensors popover printed every temperature in the same dim grey, so a CPU 4 C from its critical trip looked exactly like one at idle. Rows now take their colour from SensorReading.severity, and so does the chip on the bar itself -- a reading that needs attention should be noticeable without opening anything, since a popover nobody opens conveys nothing. The ramp is dim, plain, amber, red, using the stock GTK "warning" and "error" classes rather than a palette of our own: those are already defined by every theme and already legible on its background, where a hand-picked amber would collide with the user accent and need maintaining separately for light and dark. WARM deliberately gets no class at all -- undimming to the ordinary foreground is the first step of the ramp, and colour is spent only where it means something. The summary label removes the previous tick classes before adding the current one. add_css_class is additive, so without that the chip would stay red for the rest of the session once the machine had been hot once. Clocks are deliberately NOT coloured. A core at its maximum is doing its job, and painting it red trains the user to ignore the colour that does mean something. They are instead shown against their own ceiling, which is not one number per machine: CIX Sky1 exposes five cpufreq policies with five different maxima, so 1.4 GHz is nearly flat out on one cluster and near idle on another. Requires the Severity and ClockReading API added to libsingularity. --- src/components/panel/panel.vala | 76 ++++++++++++++++++++++++++++++--- 1 file changed, 71 insertions(+), 5 deletions(-) diff --git a/src/components/panel/panel.vala b/src/components/panel/panel.vala index e8cb138..32e0b1b 100644 --- a/src/components/panel/panel.vala +++ b/src/components/panel/panel.vala @@ -109,6 +109,33 @@ namespace Singularity { ? monitor.cpu_millidegrees : monitor.system_millidegrees; + // Colour the chip on the bar, not only the rows inside the + // popover. A temperature that needs attention is worth noticing + // WITHOUT opening anything -- a popover nobody opens conveys + // nothing. The severity shown is the one belonging to the sensor + // whose number is displayed, so the colour and the figure always + // describe the same sensor. + SensorKind primary_kind = monitor.cpu_millidegrees >= 0 + ? SensorKind.CPU + : SensorKind.SYSTEM; + Severity primary_severity = Severity.NORMAL; + foreach (SensorReading reading in monitor.readings()) { + if (reading.kind == primary_kind + && reading.millidegrees == primary) { + primary_severity = reading.severity; + break; + } + } + // Drop whatever the last tick set before setting this one: + // add_css_class is additive, so an unremoved "error" would stay + // red for the rest of the session once the machine had been hot. + summary_label.remove_css_class("warning"); + summary_label.remove_css_class("error"); + string? summary_css = severity_css(primary_severity); + if (summary_css != null && summary_css != "dim-label") { + summary_label.add_css_class(summary_css); + } + StringBuilder text = new StringBuilder(); if (primary >= 0) { text.append(format_celsius(primary)); @@ -135,14 +162,41 @@ namespace Singularity { detail_box.append(heading); } - private void add_row(string name, string value) { + /** + * CSS class for a severity, or null to leave the label unstyled. + * + * These are GTK stock classes, not a palette of our own. A hand-picked + * amber and red would collide with whatever accent the user's theme + * uses and would need maintaining for light and dark separately; + * "warning" and "error" are already defined by every GTK theme and + * already legible on its background. + * + * NORMAL keeps the dim treatment the rows have always had, and WARM + * deliberately gets NOTHING -- undimming to the ordinary foreground is + * the first step of the ramp. Colour is spent only where it means + * something: dim, plain, amber, red. + */ + private static string? severity_css(Severity severity) { + switch (severity) { + case Severity.CRITICAL: return "error"; + case Severity.HOT: return "warning"; + case Severity.WARM: return null; + default: return "dim-label"; + } + } + + private void add_row(string name, string value, + Severity severity = Severity.NORMAL) { Box row = new Box(Orientation.HORIZONTAL, 12); Label name_label = new Label(name); name_label.halign = Align.START; name_label.hexpand = true; Label value_label = new Label(value); value_label.halign = Align.END; - value_label.add_css_class("dim-label"); + string? css = severity_css(severity); + if (css != null) { + value_label.add_css_class(css); + } row.append(name_label); row.append(value_label); detail_box.append(row); @@ -171,7 +225,8 @@ namespace Singularity { continue; } if (shown < MAX_ROWS_PER_GROUP) { - add_row(reading.label, format_celsius(reading.millidegrees)); + add_row(reading.label, format_celsius(reading.millidegrees), + reading.severity); shown++; } else { hidden++; @@ -194,11 +249,22 @@ namespace Singularity { add_group(SensorKind.GPU, _("GPU")); add_group(SensorKind.SYSTEM, _("System")); - int[] clocks = monitor.clocks_khz(); + // Clocks are NOT colour-coded. A core at its maximum is doing its + // job, not overheating, and painting it red would train the user to + // ignore the colour that does mean something. They are shown + // against their own ceiling instead, because that ceiling is not + // one number per machine: CIX Sky1 has five cpufreq policies with + // five different maxima, so "1.4 GHz" is nearly flat out on one + // cluster and near idle on another. + ClockReading[] clocks = monitor.clocks(); if (clocks.length > 0) { add_heading(_("Clocks")); for (int i = 0; i < clocks.length; i++) { - add_row(_("Core group %d").printf(i + 1), format_clock(clocks[i])); + string value = clocks[i].max_khz > 0 + ? "%s / %s".printf(format_clock(clocks[i].khz), + format_clock(clocks[i].max_khz)) + : format_clock(clocks[i].khz); + add_row(_("Core group %d").printf(i + 1), value); } } } From d6418257ce84d5720961c09ddafaad48ae9d192f Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Sun, 16 Aug 2026 17:05:00 +0000 Subject: [PATCH 05/21] shell: identify the CPU and GPU on the shipping Sky1 sensor topology MEASURED on two CIX Sky1 machines whose sensor topologies differ completely, decided by one kernel command line flag: cixmini, 7.0.12-cix-sky1-next, no acpi_scmi_en flag one hwmon chip "scmi_sensors" carrying 22 LABELLED sensors -- CPU_B0, CPU_B1, CPU_M0, CPU_M1, GPU_AVE, GPU_top, GPU_btm, NPU, VPU, DDR_top, DDR_btm, PCB_AMB, PCB_HOT, SOC_TRC, ... O6N, 7.2.0-rc7-sky1-ncz, acpi_scmi_en=off no scmi_sensors at all -- five bare ACPI thermal zones named TZB0 TZB1 TZM0 TZM1 TZGT, with no labels and no tempN_crit SCMI is disabled deliberately on 7.2, so the second topology is what we ship. There the allow-lists inside SensorMonitor cannot help: the identity of a sensor lives in a four-character ACPI name and nowhere else. Probed on O6N, the panel reported cpu=-1 gpu=-1 -- no CPU and no GPU temperature on the board this product targets. cpu_hint and gpu_hint are the documented extension point for exactly this ("hardware the allow-list cannot know... a distribution sets these"), so this is a configuration change rather than another vendor string baked into libsingularity. TZB is the big cluster, TZM the mid cluster, TZGT graphics. classify() tests gpu_hint before cpu_hint, so the specific TZGT claims the GPU before the broader TZ claims the rest. Verified on O6N hardware, before and after: before cpu=-1 gpu=-1 system=70850 after cpu=49000 gpu=46000 system=70850 with TZGT GPU, TZB0/TZB1/TZM0/TZM1 CPU, and nvme plus both r8169 NICs still correctly SYSTEM. Both hints are inert on the scmi_sensors topology, where no chip or label contains "TZ", so one configuration serves both kernels. Worth recording separately: the 7.2 configuration costs the user NPU, VPU, DDR, PCB and SOC temperatures outright -- 22 sensors become 5. --- src/core/system_monitor.vala | 38 +++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/src/core/system_monitor.vala b/src/core/system_monitor.vala index a7b0a3c..bdd4202 100644 --- a/src/core/system_monitor.vala +++ b/src/core/system_monitor.vala @@ -16,7 +16,43 @@ namespace Singularity { public BluetoothManager bluetooth { get { if (_bluetooth == null) _bluetooth = new BluetoothManager(); return _bluetooth; } } public PowerProfilesManager power_profiles { get { if (_power_profiles == null) _power_profiles = new PowerProfilesManager(); return _power_profiles; } } public ResourceMonitor resources { get { if (_resources == null) _resources = new ResourceMonitor(); return _resources; } } - public SensorMonitor sensors { get { if (_sensors == null) _sensors = new SensorMonitor(); return _sensors; } } + /** + * Sensors, with the CIX Sky1 naming hints applied. + * + * MEASURED 2026-08-16 on two Sky1 machines that present COMPLETELY + * DIFFERENT sensor topologies, decided by one kernel command line flag: + * + * cixmini, 7.0.12-cix-sky1-next, no acpi_scmi_en flag + * -> one hwmon chip "scmi_sensors" carrying 22 LABELLED sensors + * (CPU_B0, CPU_M1, GPU_AVE, NPU, VPU, DDR_top, PCB_AMB, ...) + * + * O6N, 7.2.0-rc7-sky1-ncz, acpi_scmi_en=off + * -> no scmi_sensors at all; five bare ACPI thermal zones named + * TZB0 TZB1 TZM0 TZM1 TZGT, with NO labels and no tempN_crit + * + * We disable SCMI on 7.2 deliberately, so the shipping configuration is + * the second one. There the allow-lists in SensorMonitor cannot help -- + * the identity is in a four-character ACPI name and nowhere else -- and + * the panel reported cpu=-1 gpu=-1 on the board this product targets. + * + * TZB = big cluster, TZM = mid cluster, TZGT = graphics. gpu_hint is + * tested before cpu_hint by SensorMonitor.classify(), so the more + * specific TZGT claims the GPU before the broader TZ claims the rest. + * Verified on O6N: cpu=49000 gpu=46000, with nvme and both r8169 NICs + * still correctly SYSTEM. Both hints are inert on the scmi_sensors + * topology, where no chip or label contains "TZ", so one configuration + * serves both kernels. + */ + public SensorMonitor sensors { + get { + if (_sensors == null) { + _sensors = new SensorMonitor(); + _sensors.gpu_hint = "TZGT"; + _sensors.cpu_hint = "TZ"; + } + return _sensors; + } + } public CallMonitor call_monitor { get { if (_call_monitor == null) _call_monitor = new CallMonitor(audio); return _call_monitor; } } private PowerManager? _power; From 4fb572a036768d5b8e2d9a9e9c47705c243f2fd1 Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Sun, 16 Aug 2026 18:20:00 +0000 Subject: [PATCH 06/21] shell(panel): populate the sensors popover the moment it opens Reported from the machine: the sensors panel "takes multiple times to poll and show the entries". Cause: rebuild_details() runs only from on_updated(), and only when the popover is ALREADY visible. So the first open showed an empty box and stayed empty until the refresh timer next fired -- up to a full interval, two seconds by default. Open it, see nothing, close it, open it again, and by then a tick has landed and the rows appear. That reads exactly like needing several tries. Refresh when the popover becomes visible. That both populates it immediately and means the figures shown are the ones at the instant of opening, rather than up to an interval stale. refresh() publishes the sysfs sources synchronously and then emits updated(), so the existing on_updated() path does the rebuild -- there is no second rendering path to keep in step. The NVIDIA query stays asynchronous and lands on a later tick exactly as before. --- src/components/panel/panel.vala | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/components/panel/panel.vala b/src/components/panel/panel.vala index 32e0b1b..0373be2 100644 --- a/src/components/panel/panel.vala +++ b/src/components/panel/panel.vala @@ -51,6 +51,28 @@ namespace Singularity { popover.child = detail_box; button.popover = popover; + // Populate the moment the popover opens, not on the next tick. + // + // rebuild_details() runs only from on_updated(), and only when the + // popover is ALREADY visible -- so the first open showed an empty + // box and stayed empty until the timer next fired. With the + // default two-second interval that reads as "the sensors take a + // few tries to appear", which is exactly how it was reported from + // the machine. Refreshing here also means the figures shown are + // the ones at the instant of opening rather than up to a full + // interval stale. + // + // refresh() publishes synchronously for the sysfs sources and then + // emits updated(), so the existing on_updated() path does the + // rebuild; there is no second code path to keep in step. The + // NVIDIA query stays asynchronous and lands on a later tick as + // before. + popover.notify["visible"].connect(() => { + if (popover.visible) { + monitor.refresh(); + } + }); + monitor = SystemMonitor.get_default().sensors; // Every settings read is guarded: this widget and the schema can From a2d9c68258cdb1f96e37cda39d89f8cd74be7f82 Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Sun, 16 Aug 2026 20:00:00 +0000 Subject: [PATCH 07/21] shell(panel): show every sensor group, name each row, and draw the heat bar Three defects, all visible on one photograph of the running panel. THE WIDER GROUPS WERE NEVER RENDERED. rebuild_details() listed only CPU, GPU and SYSTEM, so the NPU, VPU, MEMORY, STORAGE, NETWORK and BOARD kinds were classified by the backend and then silently dropped. On Sky1 that hid eleven of nineteen readings, including the NVMe at 68 C -- the one sensor on the board actually worth looking at. add_group() already skips an empty kind, so a machine reporting only CPU and GPU still shows exactly two headings. THE ROWS HAD NO NAMES. add_row() built name_label, set its alignment, and never appended it, so every row rendered as a bar and a temperature with no way to tell which sensor it was. Now appended, ellipsized at 22 characters with the full name on a tooltip so a long chip+label cannot push the reading off the popover. THE BAR WAS ALWAYS RED. It was a Gtk.LevelBar, and GTK gives a LevelBar its own offset classes (level-low / level-high / level-full) which themes style with BATTERY semantics -- low means trouble, painted red. Every sensor therefore showed a short red bar regardless of temperature, so a 46 C CPU looked exactly as alarming as a hot drive. Overriding that meant fighting theme rules on a widget whose entire purpose is to be themed. It is now a DrawingArea that owns its pixels: cool blue, green, amber, orange, red, chosen here and identical on every machine and theme. Five discrete steps rather than a continuous gradient, because a gradient needs a CssProvider per row and rebuilding twenty of them on each popover open is real cost for a difference nobody can see. Verified on O6N: nine groups render, each row names its sensor, bars read blue at 45-49 C, and the 68 C NVMe is the single orange bar on the panel. --- src/components/panel/panel.vala | 105 ++++++++++++++++++++++++++++++-- 1 file changed, 99 insertions(+), 6 deletions(-) diff --git a/src/components/panel/panel.vala b/src/components/panel/panel.vala index 0373be2..5862c4f 100644 --- a/src/components/panel/panel.vala +++ b/src/components/panel/panel.vala @@ -207,19 +207,98 @@ namespace Singularity { } } + /** + * The heat bar, drawn rather than themed. + * + * This started as a Gtk.LevelBar and that was wrong. GTK gives a + * LevelBar its own offset classes (level-low / level-high / level-full) + * and the theme styles them with BATTERY semantics, where low means + * trouble and is painted red. The result on real hardware was every + * sensor showing a short red bar regardless of temperature -- a 46 C + * CPU rendered exactly as alarming as a hot drive, which is worse than + * no bar at all. Overriding it meant fighting theme rules on a widget + * whose whole purpose is to be themed. + * + * A DrawingArea owns its pixels. No theme rule can reach it, the ramp + * means the same thing on every machine, and the colours are the ones + * chosen here rather than whatever "low" happens to mean to a theme. + */ + private const double[] HEAT_STOPS = { 0.40, 0.55, 0.70, 0.85 }; + + private static void heat_rgb(double f, out double r, out double g, out double b) { + // cool blue -> green -> amber -> orange -> red + if (f < HEAT_STOPS[0]) { r = 0.29; g = 0.56; b = 0.85; } + else if (f < HEAT_STOPS[1]) { r = 0.20; g = 0.63; b = 0.44; } + else if (f < HEAT_STOPS[2]) { r = 0.83; g = 0.63; b = 0.09; } + else if (f < HEAT_STOPS[3]) { r = 0.88; g = 0.42; b = 0.12; } + else { r = 0.84; g = 0.24; b = 0.24; } + } + + private Gtk.DrawingArea make_heat_bar(double heat) { + var area = new Gtk.DrawingArea(); + area.content_width = 72; + area.content_height = 6; + area.valign = Align.CENTER; + double f = heat.clamp(0.0, 1.0); + area.set_draw_func((a, cr, w, h) => { + double radius = h / 2.0; + // Trough: a faint neutral track, so an almost-empty bar still + // reads as a bar and not as a rendering glitch. + cr.set_source_rgba(0.5, 0.5, 0.5, 0.25); + rounded_rect(cr, 0, 0, w, h, radius); + cr.fill(); + if (f <= 0.0) { + return; + } + double fill_w = double.max(h, w * f); + double r, g, b; + heat_rgb(f, out r, out g, out b); + cr.set_source_rgb(r, g, b); + rounded_rect(cr, 0, 0, fill_w, h, radius); + cr.fill(); + }); + return area; + } + + private static void rounded_rect(Cairo.Context cr, double x, double y, + double w, double h, double r) { + cr.new_sub_path(); + cr.arc(x + w - r, y + r, r, -Math.PI / 2, 0); + cr.arc(x + w - r, y + h - r, r, 0, Math.PI / 2); + cr.arc(x + r, y + h - r, r, Math.PI / 2, Math.PI); + cr.arc(x + r, y + r, r, Math.PI, 3 * Math.PI / 2); + cr.close_path(); + } + private void add_row(string name, string value, - Severity severity = Severity.NORMAL) { + Severity severity = Severity.NORMAL, + double heat = -1.0) { Box row = new Box(Orientation.HORIZONTAL, 12); Label name_label = new Label(name); name_label.halign = Align.START; name_label.hexpand = true; + // Long sensor names must not push the reading off the popover. + name_label.ellipsize = Pango.EllipsizeMode.END; + name_label.max_width_chars = 22; + name_label.tooltip_text = name; + row.append(name_label); + + // The bar carries the MAGNITUDE, the label colour carries the + // ALARM. They are different questions: on a healthy machine every + // sensor is NORMAL and the labels say nothing, while the bars + // still show which part of the board is warmest. Measured on O6N: + // 20 readings, 19 of them NORMAL, and the NVMe at 0.74 is the only + // one that stands out -- but only because of the bar. + if (heat >= 0.0) { + row.append(make_heat_bar(heat)); + } + Label value_label = new Label(value); value_label.halign = Align.END; string? css = severity_css(severity); if (css != null) { value_label.add_css_class(css); } - row.append(name_label); row.append(value_label); detail_box.append(row); } @@ -248,7 +327,7 @@ namespace Singularity { } if (shown < MAX_ROWS_PER_GROUP) { add_row(reading.label, format_celsius(reading.millidegrees), - reading.severity); + reading.severity, reading.heat_fraction); shown++; } else { hidden++; @@ -267,9 +346,23 @@ namespace Singularity { child = detail_box.get_first_child(); } - add_group(SensorKind.CPU, _("CPU")); - add_group(SensorKind.GPU, _("GPU")); - add_group(SensorKind.SYSTEM, _("System")); + // Every kind the backend can name, hottest-silicon first and the + // board last. add_group() skips a kind with no sensors, so a PC + // that reports only CPU and GPU still shows exactly two headings. + // + // This list previously stopped at SYSTEM, which meant the wider + // kinds were classified and then silently dropped -- on Sky1 that + // hid eleven of nineteen readings, including the NVMe that was the + // only one worth looking at. + add_group(SensorKind.CPU, _("CPU")); + add_group(SensorKind.GPU, _("GPU")); + add_group(SensorKind.NPU, _("NPU")); + add_group(SensorKind.VPU, _("VPU")); + add_group(SensorKind.MEMORY, _("Memory")); + add_group(SensorKind.STORAGE, _("Storage")); + add_group(SensorKind.NETWORK, _("Network")); + add_group(SensorKind.BOARD, _("Board")); + add_group(SensorKind.SYSTEM, _("System")); // Clocks are NOT colour-coded. A core at its maximum is doing its // job, not overheating, and painting it red would train the user to From 45e9a43e28c7df06e4d7e3c97dc82e8055acf054 Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Mon, 17 Aug 2026 18:43:01 +0000 Subject: [PATCH 08/21] =?UTF-8?q?fix:=20address=20Codex=20review=20?= =?UTF-8?q?=E2=80=94=20don't=20clobber=20platform=20hints,=20make=20sensor?= =?UTF-8?q?s=20a=20real=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sensors-gpu-zone/sensors-cpu-zone: only override monitor.gpu_hint/cpu_hint when the user actually configured a non-empty zone name. The schema's portable default is empty, and unconditionally assigning it clobbered the Sky1 TZGT/TZ hints SystemMonitor.sensors sets up internally -- silently defeating the CPU/GPU identification commit on first load with default settings. item_ids/default_center: sensors is now genuinely in default_center, same as system/notifications/clock, matching how BarLayout actually treats any allowed item absent from a user's saved layout (it gets force-added to center regardless -- the prior 'stays opt-in via registration' comment did not match that behavior for a brand-new item id). Comment corrected to describe the real mechanism instead of an aspirational one. Co-Authored-By: Claude Opus 5 (1M context) --- src/components/panel/panel.vala | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/components/panel/panel.vala b/src/components/panel/panel.vala index 5862c4f..6780bbe 100644 --- a/src/components/panel/panel.vala +++ b/src/components/panel/panel.vala @@ -87,11 +87,20 @@ namespace Singularity { if (schema != null && schema.has_key("sensors-show-frequency")) { show_frequency = settings.get_boolean("sensors-show-frequency"); } + // Only override when the user has actually configured a zone name. + // The schema's portable default for these keys is an empty string, + // and monitor.gpu_hint/cpu_hint already carry the platform-specific + // TZGT/TZ hints SystemMonitor.sensors set up before this ran (the + // only way to identify CPU/GPU on the shipping Sky1 ACPI topology). + // Assigning unconditionally on "has_key" clobbered those hints with + // an empty string on every load with default settings. if (schema != null && schema.has_key("sensors-gpu-zone")) { - monitor.gpu_hint = settings.get_string("sensors-gpu-zone"); + string gpu_zone = settings.get_string("sensors-gpu-zone"); + if (gpu_zone != "") monitor.gpu_hint = gpu_zone; } if (schema != null && schema.has_key("sensors-cpu-zone")) { - monitor.cpu_hint = settings.get_string("sensors-cpu-zone"); + string cpu_zone = settings.get_string("sensors-cpu-zone"); + if (cpu_zone != "") monitor.cpu_hint = cpu_zone; } monitor.updated.connect(on_updated); @@ -994,7 +1003,13 @@ namespace Singularity { // Registered unconditionally so the greeter panel gets it too: // Panel is constructed with greeter_mode for the login screen and // shares this layout_items map. Registering an item does NOT show - // it -- placement comes from panel-layout-*, so it stays opt-in. + // it directly -- placement comes from panel-layout-*. It IS in + // default_center below, same as system/notifications/clock, so it + // shows by default on a fresh install; existing installs pick it + // up on upgrade via BarLayout's append-missing-allowed-items pass, + // same mechanism every previously-added default item went through. + // Users remove it the same way as any other default item, via the + // panel customization settings. layout_items["sensors"] = new SensorsIndicator(_settings); reload_bar_layout(); @@ -1359,7 +1374,7 @@ namespace Singularity { item_ids, { "overview", "workspaces", "app-title", "global-menu" }, { "tiling-position" }, - { "system", "notifications", "clock" }, + { "system", "notifications", "clock", "sensors" }, _settings.get_strv("panel-layout-left"), _settings.get_strv("panel-layout-center"), _settings.get_strv("panel-layout-right") From 48700ef473e070d740e842c3bf5a072436df8766 Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Mon, 17 Aug 2026 15:51:52 -0400 Subject: [PATCH 09/21] fix(panel): gate sensor polling on map state, cap unbounded clock rows Codex review (PR #21, 2026-08-17): - SensorsIndicator started its polling timer unconditionally at construction instead of on map, so a hidden/unmapped panel kept reading hwmon and running the async NVIDIA query every interval for no visible reading. Now starts on map, stops on unmap, matching the map-gated pattern already used elsewhere in this file (TilingSlotOverlay). - The Clocks section in the sensors popover had no cap and no scroll container; on a many-cpufreq-policy x86 box the list could run the popover off-screen. Capped to MAX_ROWS_PER_GROUP with an '+N more' row, same convention already used for the temperature groups above it. --- src/components/panel/panel.vala | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/components/panel/panel.vala b/src/components/panel/panel.vala index 6780bbe..0adb3ef 100644 --- a/src/components/panel/panel.vala +++ b/src/components/panel/panel.vala @@ -104,8 +104,19 @@ namespace Singularity { } monitor.updated.connect(on_updated); - monitor.start(interval); on_updated(); + + // Poll only while actually on screen. An unmapped or hidden panel + // (e.g. a secondary output's panel that isn't currently shown) + // has no visible reading, so a running timer there is pure sysfs + // churn and, on boards with an async NVIDIA query, wasted work on + // every tick -- exactly the idle cost this feature's interval + // setting exists to bound. start()/stop() are idempotent no-ops + // when already in the requested state (SensorMonitor.start/stop), + // so map/unmap can call them freely without tracking state here. + map.connect(() => monitor.start(interval)); + unmap.connect(() => monitor.stop()); + if (get_mapped()) monitor.start(interval); } public override void dispose() { @@ -383,13 +394,21 @@ namespace Singularity { ClockReading[] clocks = monitor.clocks(); if (clocks.length > 0) { add_heading(_("Clocks")); - for (int i = 0; i < clocks.length; i++) { + // Same cap-and-count convention as add_group() above: a + // per-CPU cpufreq policy (one entry per core on some x86 + // layouts) can run past a hundred, and the popover has no + // scroll container, so an uncapped list grows off-screen. + int shown = int.min(clocks.length, MAX_ROWS_PER_GROUP); + for (int i = 0; i < shown; i++) { string value = clocks[i].max_khz > 0 ? "%s / %s".printf(format_clock(clocks[i].khz), format_clock(clocks[i].max_khz)) : format_clock(clocks[i].khz); add_row(_("Core group %d").printf(i + 1), value); } + if (clocks.length > shown) { + add_row(_("%d more").printf(clocks.length - shown), ""); + } } } } From b87423a2b2fe3532026c8a81acd84d1ddfc65e45 Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Mon, 17 Aug 2026 16:36:39 -0400 Subject: [PATCH 10/21] feat(network): list every wired port in Network settings, not one summary Replaces the single Connected/Not Connected wired row with one row per NetworkManagerWrapper.ethernet_ports() entry, showing interface name, PCI chipset, and top link capability. A board with several NICs (O6N: two 2.5GbE Realtek ports) previously showed only whichever port happened to be summarized, cable in or out. --- .../sidebar/pages/network_page.vala | 54 ++++++++++++++++--- 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/src/components/sidebar/pages/network_page.vala b/src/components/sidebar/pages/network_page.vala index c18db79..53814bb 100644 --- a/src/components/sidebar/pages/network_page.vala +++ b/src/components/sidebar/pages/network_page.vala @@ -39,13 +39,10 @@ namespace Singularity { header.append(scan_btn); add_group(wifi_group); var wired_group = new PreferencesGroup(_("Wired")); - var wired_status_row = new ActionRow(_("Wired Connection")); - var wired_status_label = new Label(network.is_wired_connected ? _("Connected") : _("Not Connected")); - wired_status_label.add_css_class("dim-label"); - wired_status_row.add_suffix(wired_status_label); - wired_group.add_row(wired_status_row); - network.state_changed.connect(() => { - wired_status_label.label = network.is_wired_connected ? _("Connected") : _("Not Connected"); + var wired_rows = new List(); + update_wired_list(wired_group, ref wired_rows, network); + network.ethernet_ports_changed.connect(() => { + update_wired_list(wired_group, ref wired_rows, network); }); add_group(wired_group); @@ -293,6 +290,49 @@ namespace Singularity { } } + // One row per physical wired port, cable in or out -- a board can + // have several (O6N: two 2.5GbE Realtek ports), and a single + // "Connected"/"Not Connected" summary hid every port but whichever + // one happened to be up. + private void update_wired_list(PreferencesGroup group, ref List rows, NetworkManagerWrapper network) { + foreach (var row in rows) { + group.remove_row(row); + } + rows = new List(); + var ports = network.ethernet_ports(); + if (ports.length == 0) { + var lbl_row = new PreferencesRow(); + var lbl = new Label(_("No wired ports found")); + lbl.add_css_class("dim-label"); + lbl.margin_top = 12; + lbl.margin_bottom = 12; + lbl_row.set_child(lbl); + group.add_row(lbl_row); + rows.append(lbl_row); + return; + } + for (int i = 0; i < ports.length; i++) { + var port = ports.get(i); + string icon_name = port.connected + ? "network-wired-symbolic" : "network-wired-disconnected-symbolic"; + var row = new ActionRow(port.iface, null, icon_name); + string chipset = port.chipset != "" ? port.chipset : _("Detecting…"); + string subtitle = port.capability != "" + ? "%s · %s".printf(chipset, port.capability) : chipset; + row.subtitle = subtitle; + if (port.connected) { + row.add_suffix(new Label(_("Connected"))); + row.add_css_class("selected"); + } else { + var lbl = new Label(_("Not Connected")); + lbl.add_css_class("dim-label"); + row.add_suffix(lbl); + } + group.add_row(row); + rows.append(row); + } + } + // Shows the result of import / manual-add / remove / provider actions. private void on_vpn_action_result(bool success, string message) { if (success) return; From 19bb9a702c2fa45483a330df10d9d4f5729ed832 Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Mon, 17 Aug 2026 16:47:40 -0400 Subject: [PATCH 11/21] fix(panel): refresh sensors before the first visibility check Codex review (PR #21, 2026-08-17, commit b87423a): monitor.start() only ran once the widget was mapped, but on_updated() ran first and set visible=false when monitor.available was still its pre-refresh default (false) -- and GTK never maps an invisible widget, so map never fired and the panel's default Sensors chip could never appear. One synchronous refresh() before the first on_updated() (same call already used when the popover opens) establishes real availability first. --- src/components/panel/panel.vala | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/components/panel/panel.vala b/src/components/panel/panel.vala index 0adb3ef..9ab3ba7 100644 --- a/src/components/panel/panel.vala +++ b/src/components/panel/panel.vala @@ -104,6 +104,14 @@ namespace Singularity { } monitor.updated.connect(on_updated); + // A never-started monitor has no readings, so on_updated() below + // would see monitor.available == false and set visible = false -- + // and GTK never maps an invisible widget, so the map handler that + // would otherwise start polling never fires. One synchronous + // refresh (already used the same way when the popover opens) + // establishes real availability before that first visibility + // decision, so a fresh shell doesn't self-hide permanently. + monitor.refresh(); on_updated(); // Poll only while actually on screen. An unmapped or hidden panel From 29265a709fea129bedf47a2b5d5dcf6b0bb154ea Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Mon, 17 Aug 2026 18:29:03 -0400 Subject: [PATCH 12/21] fix(panel,sensors): scroll the aggregate popover; scope Sky1 hints to Sky1 Codex review (PR #21, 2026-08-17, commit 19bb9a7): - Per-group caps bounded each section but not the total. Nine sensor kinds x (6 rows + overflow) plus headings plus clocks reaches ~70 rows on the 55-sensor Qualcomm topology this change calls out, running off the bottom of the screen with the lower groups unreachable. The popover child is now a ScrolledWindow with propagate_natural_height, so small machines render byte-identically to before and only genuinely oversized content scrolls. - The TZGT/TZ classification hints are four-character CIX Sky1 ACPI names, but SystemMonitor applied them on every platform. Now gated on actually being a Sky1 board. MEASURED on O6N while writing that gate, and it changed the implementation: the obvious check (DMI vendor / devicetree contains cix or sky1) returns FALSE on real Sky1 hardware -- the shipping kernel is ACPI so there is no devicetree at all, and every DMI string reads Radxa ... Orion O6N, never CIX or Sky1. Shipping that would have silently restored the cpu=-1/gpu=-1 bug these hints exist to fix. Detection therefore keys on the SoC's own ACPI HIDs (CIXH*, 163 of which enumerate on that machine), with devicetree kept as a fallback for a DT-booted Sky1. Also verified already-fixed and re-anchored rather than re-fixed: the P1 'start polling before hiding the uninitialized indicator' finding (monitor.refresh() before the first on_updated() landed in 19bb9a7). --- src/components/panel/panel.vala | 19 +++++++++- src/core/system_monitor.vala | 66 ++++++++++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/src/components/panel/panel.vala b/src/components/panel/panel.vala index 9ab3ba7..bacf91d 100644 --- a/src/components/panel/panel.vala +++ b/src/components/panel/panel.vala @@ -48,7 +48,24 @@ namespace Singularity { detail_box.margin_start = 12; detail_box.margin_end = 12; Popover popover = new Popover(); - popover.child = detail_box; + // Bound the WHOLE popover, not just each group. + // + // The per-group cap (MAX_ROWS_PER_GROUP) limits any single + // section, but nine sensor kinds plus headings plus the clock + // section still add up: on the 55-sensor Qualcomm topology this + // change explicitly targets, the aggregate reaches roughly 70 + // rows and runs off the bottom of the screen, making the lower + // groups unreachable -- capped or not. propagate_natural_height + // keeps small machines rendering exactly as before (the popover + // shrinks to fit two or three groups); only once the content + // genuinely exceeds max_content_height does it start scrolling. + var detail_scroller = new ScrolledWindow(); + detail_scroller.child = detail_box; + detail_scroller.propagate_natural_height = true; + detail_scroller.propagate_natural_width = true; + detail_scroller.max_content_height = 600; + detail_scroller.hscrollbar_policy = PolicyType.NEVER; + popover.child = detail_scroller; button.popover = popover; // Populate the moment the popover opens, not on the next tick. diff --git a/src/core/system_monitor.vala b/src/core/system_monitor.vala index bdd4202..7705c01 100644 --- a/src/core/system_monitor.vala +++ b/src/core/system_monitor.vala @@ -47,12 +47,74 @@ namespace Singularity { get { if (_sensors == null) { _sensors = new SensorMonitor(); - _sensors.gpu_hint = "TZGT"; - _sensors.cpu_hint = "TZ"; + // Scope these to the hardware they were measured on. + // + // These are four-character ACPI names specific to the CIX + // Sky1 topology, not general heuristics, so applying them + // on every platform makes a Sky1 quirk everyone else's + // problem. The substring match is case-sensitive, so the + // lowercase x86 "acpitz" chip does not in fact collide + // with "TZ" -- but relying on that is a coincidence, not + // a design, and it would break the moment any platform + // exposed an uppercase label containing TZ. Gate on the + // actual board instead: inert everywhere else by + // construction rather than by luck. + if (is_cix_sky1()) { + _sensors.gpu_hint = "TZGT"; + _sensors.cpu_hint = "TZ"; + } } return _sensors; } } + + /** + * True on CIX Sky1 boards (Radxa Orion O6/O6N, cixmini). + * + * Detects the SoC by its own ACPI hardware IDs rather than by board + * branding. MEASURED on an O6N running the shipping ACPI kernel: + * there is no devicetree at all, and every DMI vendor/product string + * says "Radxa ... Orion O6N" -- not "CIX" and not "Sky1" -- so a + * vendor-string match reports FALSE on the exact hardware these + * hints exist for, silently restoring the cpu=-1/gpu=-1 bug they + * were added to fix. The CIXH* HIDs are the SoC's, not the board + * vendor's: 163 of them enumerate on that same machine. Devicetree + * is still checked so a DT-booted Sky1 is covered too. + */ + private static bool is_cix_sky1() { + try { + Dir acpi = Dir.open("/sys/bus/acpi/devices", 0); + string? name; + while ((name = acpi.read_name()) != null) { + if (name.has_prefix("CIXH")) { + return true; + } + } + } catch (FileError e) { + // No ACPI bus (a DT-only kernel); fall through. + } + + string[] dt_probes = { + "/proc/device-tree/compatible", + "/sys/firmware/devicetree/base/compatible", + }; + foreach (string path in dt_probes) { + string contents; + try { + if (!FileUtils.get_contents(path, out contents)) { + continue; + } + } catch (FileError e) { + continue; + } + // "compatible" is NUL-separated, so match the raw buffer. + string lowered = contents.down(); + if (lowered.contains("cix") || lowered.contains("sky1")) { + return true; + } + } + return false; + } public CallMonitor call_monitor { get { if (_call_monitor == null) _call_monitor = new CallMonitor(audio); return _call_monitor; } } private PowerManager? _power; From 88886a5a77f946f18f9dfabaa93bd0397a587dfe Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Mon, 17 Aug 2026 19:03:34 -0400 Subject: [PATCH 13/21] fix(panel,sensors): survive sensor dropout, read all DT compatible entries, honour the frequency toggle, never render an empty chip Codex review (PR #21, commit 29265a7). Four real findings, two of them in code added by that same commit: - Availability was switching off the mechanism that detects availability. If a later refresh reported nothing readable (hwmon driver reloading, a GPU power-gated, a sensor hot-unplugged) the widget hid itself, which unmaps it, which fired the unmap handler and stopped the poll timer -- after which nothing could ever observe the sensors returning and the chip stayed gone until the shell restarted. A self-inflicted unmap is now distinguished from a real one and keeps polling. - The devicetree fallback in is_cix_sky1() read compatible with FileUtils.get_contents and matched the resulting Vala string, which stops at the first NUL. compatible is a NUL-SEPARATED list ordered most specific first (radxa,\0cix,sky1), so only the board entry was ever examined and cix,sky1 was missed -- on exactly the DT-booted configuration that fallback exists to catch. Now reads the real byte array via load_contents and inspects every entry. - sensors-show-frequency gated only the compact summary, so the whole Clocks section still rendered on opening the popover; the preference did half of what it claimed. - available == true does not imply a CPU or SYSTEM reading exists. On a machine whose sensors all classify as GPU/STORAGE/NETWORK both selections were -1 and, with cpufreq also unavailable, the chip rendered as an empty label beside a popover full of valid temperatures. Falls back to the hottest reading of any kind, carrying its kind so the colour still describes the number. --- src/components/panel/panel.vala | 48 +++++++++++++++++++++++++++++++-- src/core/system_monitor.vala | 21 +++++++++++---- 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/src/components/panel/panel.vala b/src/components/panel/panel.vala index bacf91d..fa800a3 100644 --- a/src/components/panel/panel.vala +++ b/src/components/panel/panel.vala @@ -27,6 +27,10 @@ namespace Singularity { private Box detail_box; private SensorMonitor monitor; private bool show_frequency = true; + // Set when on_updated() hides the chip because no sensors are + // readable, so the unmap handler can tell a self-inflicted unmap + // (must keep polling, or recovery is never observed) from a real one. + private bool hidden_for_unavailable = false; public SensorsIndicator(GLib.Settings settings) { Object(orientation: Orientation.HORIZONTAL, spacing: 0); @@ -140,7 +144,10 @@ namespace Singularity { // when already in the requested state (SensorMonitor.start/stop), // so map/unmap can call them freely without tracking state here. map.connect(() => monitor.start(interval)); - unmap.connect(() => monitor.stop()); + unmap.connect(() => { + if (hidden_for_unavailable) return; + monitor.stop(); + }); if (get_mapped()) monitor.start(interval); } @@ -163,9 +170,21 @@ namespace Singularity { private void on_updated() { if (!monitor.available) { // Nothing readable on this hardware: hide rather than show zeros. + // + // Availability must not switch off the mechanism that detects + // availability. Hiding unmaps the widget, which fires the + // unmap handler below and would stop the poll timer -- after + // which nothing can ever observe the sensors coming back, so + // a momentary gap (hwmon driver reloading, a GPU power-gated, + // a sensor hot-unplugged) would remove the chip until the + // shell restarted. The flag tells the unmap handler this + // particular unmap is self-inflicted and polling must survive + // it; a real unmap (panel genuinely off screen) still stops. + hidden_for_unavailable = true; visible = false; return; } + hidden_for_unavailable = false; visible = true; // Prefer a sensor positively identified as the CPU. The backend @@ -185,6 +204,27 @@ namespace Singularity { SensorKind primary_kind = monitor.cpu_millidegrees >= 0 ? SensorKind.CPU : SensorKind.SYSTEM; + + // Last resort: the hottest reading of ANY kind. + // + // available == true only means SOMETHING is readable, not that a + // CPU or SYSTEM reading exists. A machine whose sensors all + // classify as GPU/STORAGE/NETWORK leaves both selections above at + // -1, and with cpufreq also unavailable the chip renders as an + // empty label -- a blank control sitting next to a popover full + // of perfectly good temperatures. Showing the hottest reading is + // both non-empty and the one worth surfacing; taking its kind too + // keeps the colour describing the number, which is the invariant + // the severity block below depends on. + if (primary < 0) { + foreach (SensorReading reading in monitor.readings()) { + if (reading.millidegrees > primary) { + primary = reading.millidegrees; + primary_kind = reading.kind; + } + } + } + Severity primary_severity = Severity.NORMAL; foreach (SensorReading reading in monitor.readings()) { if (reading.kind == primary_kind @@ -416,7 +456,11 @@ namespace Singularity { // one number per machine: CIX Sky1 has five cpufreq policies with // five different maxima, so "1.4 GHz" is nearly flat out on one // cluster and near idle on another. - ClockReading[] clocks = monitor.clocks(); + // Honour sensors-show-frequency here too. It previously gated + // only the compact summary, so turning frequency "off" still + // rendered the entire Clocks section the moment the popover was + // opened -- the preference silently did half of what it says. + ClockReading[] clocks = show_frequency ? monitor.clocks() : new ClockReading[0]; if (clocks.length > 0) { add_heading(_("Clocks")); // Same cap-and-count convention as add_group() above: a diff --git a/src/core/system_monitor.vala b/src/core/system_monitor.vala index 7705c01..6f50d37 100644 --- a/src/core/system_monitor.vala +++ b/src/core/system_monitor.vala @@ -99,16 +99,27 @@ namespace Singularity { "/sys/firmware/devicetree/base/compatible", }; foreach (string path in dt_probes) { - string contents; + // "compatible" is a NUL-SEPARATED list, conventionally most + // specific first: "radxa,\0cix,sky1". Reading it into a + // Vala string and matching that stops at the first NUL, so + // only the board entry is ever examined and the "cix,sky1" + // that identifies the SoC is missed -- on precisely the + // DT-booted configuration this fallback exists to catch. + // load_contents() returns the real byte array, so every entry + // is inspected. + uint8[] raw; try { - if (!FileUtils.get_contents(path, out contents)) { + if (!File.new_for_path(path).load_contents(null, out raw, null)) { continue; } - } catch (FileError e) { + } catch (Error e) { continue; } - // "compatible" is NUL-separated, so match the raw buffer. - string lowered = contents.down(); + var joined = new StringBuilder(); + foreach (uint8 b in raw) { + joined.append_c(b == 0 ? ' ' : (char) b); + } + string lowered = joined.str.down(); if (lowered.contains("cix") || lowered.contains("sky1")) { return true; } From 88c0d7065c9892d2f580319fc37e2c111c61ca01 Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Wed, 19 Aug 2026 10:55:48 -0400 Subject: [PATCH 14/21] Show CPU, memory and disk utilisation in the sensors panel Adds live utilisation next to the temperatures: CPU and memory percentages in the compact chip, and a Processor / Memory / Storage breakdown in the popover with per-core rows, RAM and swap, per-volume capacity and per-device disk activity. Gated on a new sensors-show-utilization key, read through the same schema.has_key() guard every other settings read here uses, and honoured in BOTH the compact chip and the popover. sensors-show-frequency previously gated only the chip, so the preference silently did half of what it said; this does not repeat that. Polling starts and stops with map/unmap alongside the sensor monitor, and survives the self-inflicted unmap that hiding-for-unavailable causes -- otherwise hiding the chip would stop the timer that would notice the machine recovering. Two behaviours worth calling out: - Temperatures being unreadable no longer hides the whole indicator. /proc/stat and /proc/meminfo exist on every Linux machine, including the many with no hwmon at all and VMs exposing no thermal zones. The old test hid a control that had perfectly good CPU and memory figures to show. Availability is probed via memory, the one figure that is valid on the first sample. - Every fraction is checked against < 0 before formatting. The monitor reports -1.0 for "not known yet" and for "no swap configured", and multiplying that by 100 renders "-100%" -- observed on cixmini, which has no swap. Capacity and memory are colour-coded at the same 0.85/0.95 thresholds ResourceMonitor already alerts on, so the panel turns amber when the notification fires rather than at some second, unrelated number. CPU and disk BUSY are deliberately not coloured, for the reason the Clocks section already documents: a core at 100% is doing its job, and painting it red trains the user to ignore the colour that does mean something. NOT COMPILE-VERIFIED. cixmini cannot build the shell at all: our libgtk4-layer-shell0 1.3.0-1+ncz20260811 package ships runtime only -- no headers, no .pc, no vapi -- and Debian's libgtk4-layer-shell-dev 1.3.0-1+b1 refuses to co-install against it. The libsingularity half of this change IS verified (16 tests green, checked against real /proc). One compile error was caught statically here before commit: Severity has no WARNING member, only NORMAL/WARM/HOT/CRITICAL. --- src/components/panel/panel.vala | 209 +++++++++++++++++++++++++++++++- src/core/system_monitor.vala | 18 +++ 2 files changed, 224 insertions(+), 3 deletions(-) diff --git a/src/components/panel/panel.vala b/src/components/panel/panel.vala index fa800a3..1cc7daf 100644 --- a/src/components/panel/panel.vala +++ b/src/components/panel/panel.vala @@ -27,6 +27,8 @@ namespace Singularity { private Box detail_box; private SensorMonitor monitor; private bool show_frequency = true; + private bool show_utilization = true; + private UtilizationMonitor util; // Set when on_updated() hides the chip because no sensors are // readable, so the unmap handler can tell a self-inflicted unmap // (must keep polling, or recovery is never observed) from a real one. @@ -95,6 +97,7 @@ namespace Singularity { }); monitor = SystemMonitor.get_default().sensors; + util = SystemMonitor.get_default().utilization; // Every settings read is guarded: this widget and the schema can // ship from different packages, and an unguarded read of a missing @@ -108,6 +111,9 @@ namespace Singularity { if (schema != null && schema.has_key("sensors-show-frequency")) { show_frequency = settings.get_boolean("sensors-show-frequency"); } + if (schema != null && schema.has_key("sensors-show-utilization")) { + show_utilization = settings.get_boolean("sensors-show-utilization"); + } // Only override when the user has actually configured a zone name. // The schema's portable default for these keys is an empty string, // and monitor.gpu_hint/cpu_hint already carry the platform-specific @@ -125,6 +131,8 @@ namespace Singularity { } monitor.updated.connect(on_updated); + util.interval_seconds = interval; + util.updated.connect(on_updated); // A never-started monitor has no readings, so on_updated() below // would see monitor.available == false and set visible = false -- // and GTK never maps an invisible widget, so the map handler that @@ -143,17 +151,26 @@ namespace Singularity { // setting exists to bound. start()/stop() are idempotent no-ops // when already in the requested state (SensorMonitor.start/stop), // so map/unmap can call them freely without tracking state here. - map.connect(() => monitor.start(interval)); + map.connect(() => { + monitor.start(interval); + if (show_utilization) util.start(); + }); unmap.connect(() => { if (hidden_for_unavailable) return; monitor.stop(); + util.stop(); }); - if (get_mapped()) monitor.start(interval); + if (get_mapped()) { + monitor.start(interval); + if (show_utilization) util.start(); + } } public override void dispose() { monitor.updated.disconnect(on_updated); monitor.stop(); + util.updated.disconnect(on_updated); + util.stop(); base.dispose(); } @@ -167,8 +184,68 @@ namespace Singularity { : "%d MHz".printf(khz / 1000); } + /** + * True when utilisation has something real to show. + * + * Memory is the probe because it is the one figure that is available + * on the FIRST sample -- CPU and disk are rates and read -1.0 until a + * second one lands, so testing those would report "unavailable" for + * one interval on every start. + */ + private bool utilization_available() { + return show_utilization && util.memory_fraction >= 0.0; + } + + private static int percent_of(double fraction) { + int p = (int) Math.round(fraction * 100.0); + if (p < 0) return 0; + return p > 100 ? 100 : p; + } + + /** + * Binary units, because that is what a filesystem reports. + * + * GIO's filesystem::size is the block count times the block size, so + * dividing by 1000 would disagree with df on the same mount and make + * the panel look wrong rather than merely differently-rounded. + */ + private static string format_bytes(uint64 bytes) { + const double K = 1024.0; + double v = (double) bytes; + if (v < K) return "%.0f B".printf(v); + v /= K; + if (v < K) return "%.0f KiB".printf(v); + v /= K; + if (v < K) return "%.0f MiB".printf(v); + v /= K; + if (v < K) return "%.1f GiB".printf(v); + return "%.1f TiB".printf(v / K); + } + + /** + * Colour a FILLED resource, but never a BUSY one. + * + * The same reasoning the Clocks section documents: a core pinned at + * 100% is doing its job and painting it red trains the user to ignore + * the colour. A disk at 100% is a machine about to stop working. So + * capacity and memory get severity and CPU/disk-busy do not. The + * thresholds match ResourceMonitor's alert points so the panel turns + * amber at the same moment the notification fires, rather than at + * some second, unrelated number. + */ + private static Severity capacity_severity(double fraction) { + if (fraction >= 0.95) return Severity.CRITICAL; + if (fraction >= 0.85) return Severity.HOT; + return Severity.NORMAL; + } + private void on_updated() { - if (!monitor.available) { + // Temperatures being unreadable no longer hides the whole chip. + // /proc/stat and /proc/meminfo exist on every Linux machine, + // including the many with no hwmon at all and VMs that expose no + // thermal zones; on those the old test hid a control that had + // perfectly good CPU and memory figures to show. + if (!monitor.available && !utilization_available()) { // Nothing readable on this hardware: hide rather than show zeros. // // Availability must not switch off the mechanism that detects @@ -253,6 +330,23 @@ namespace Singularity { } text.append(format_clock(monitor.cpu_khz)); } + // Utilisation in the compact chip, not only in the popover. + // + // Every fraction is checked against < 0 before it is formatted. + // The monitor reports -1.0 for "not known yet" (a rate needs two + // samples) and for "no swap configured", and multiplying that by + // 100 renders a confident "-100%" -- observed on cixmini, which + // has no swap. + if (show_utilization) { + if (util.cpu_fraction >= 0.0) { + if (text.len > 0) text.append(" \u00b7 "); + text.append(_("CPU %d%%").printf(percent_of(util.cpu_fraction))); + } + if (util.memory_fraction >= 0.0) { + if (text.len > 0) text.append(" \u00b7 "); + text.append(_("MEM %d%%").printf(percent_of(util.memory_fraction))); + } + } summary_label.label = text.str; Popover? popover = button.popover; @@ -388,6 +482,113 @@ namespace Singularity { detail_box.append(row); } + /** + * Live utilisation: processor, memory, storage. + * + * Gated on the SAME preference as the compact chip. A setting honoured + * in one render path and ignored in the other is how sensors-show- + * frequency shipped a half-working toggle. + */ + private void add_utilization_details() { + if (!show_utilization) { + return; + } + + // ---- processor ---- + UtilizationReading[] cores = util.per_cpu(); + if (util.cpu_fraction >= 0.0 || cores.length > 0) { + add_heading(_("Processor")); + if (util.cpu_fraction >= 0.0) { + add_row(_("Total"), "%d%%".printf(percent_of(util.cpu_fraction)), + Severity.NORMAL, util.cpu_fraction); + } + // Same cap-and-count convention as add_group(). Sky1 has 12 + // cores and server parts have far more; the popover scrolls, + // but an unbounded list still buries the temperatures under + // it. + int shown = 0; + int hidden = 0; + foreach (UtilizationReading core in cores) { + if (core.fraction < 0.0) { + continue; // first sample: no rate yet + } + if (shown < MAX_ROWS_PER_GROUP) { + add_row(core.label, "%d%%".printf(percent_of(core.fraction)), + Severity.NORMAL, core.fraction); + shown++; + } else { + hidden++; + } + } + if (hidden > 0) { + add_row(_("%d more").printf(hidden), ""); + } + } + + // ---- memory ---- + if (util.memory_fraction >= 0.0) { + add_heading(_("Memory")); + add_row(_("RAM"), + _("%s / %s").printf(format_bytes(util.memory_used_bytes), + format_bytes(util.memory_total_bytes)), + capacity_severity(util.memory_fraction), + util.memory_fraction); + // Omitted entirely when there is no swap. A "Swap 0%" row on a + // swapless machine says the swap is empty, not that there is + // none, which is a different and misleading claim. + if (util.swap_fraction >= 0.0) { + add_row(_("Swap"), "%d%%".printf(percent_of(util.swap_fraction)), + capacity_severity(util.swap_fraction), + util.swap_fraction); + } + } + + // ---- storage ---- + CapacityReading[] volumes = util.filesystems(); + UtilizationReading[] spindles = util.disks(); + if (volumes.length > 0 || spindles.length > 0) { + add_heading(_("Storage")); + + int shown = 0; + int hidden = 0; + foreach (CapacityReading vol in volumes) { + if (vol.fraction < 0.0) { + continue; + } + if (shown < MAX_ROWS_PER_GROUP) { + add_row(vol.label, + _("%s / %s").printf(format_bytes(vol.used_bytes), + format_bytes(vol.total_bytes)), + capacity_severity(vol.fraction), vol.fraction); + shown++; + } else { + hidden++; + } + } + + // Busy percentage is a RATE, not a fill level, so it is listed + // after capacity and left uncoloured -- a disk at 100% busy is + // working, a disk at 100% full is broken, and they must not + // look alike. + foreach (UtilizationReading disk in spindles) { + if (disk.fraction < 0.0) { + continue; + } + if (shown < MAX_ROWS_PER_GROUP) { + add_row(_("%s activity").printf(disk.label), + "%d%%".printf(percent_of(disk.fraction)), + Severity.NORMAL, disk.fraction); + shown++; + } else { + hidden++; + } + } + if (hidden > 0) { + add_row(_("%d more").printf(hidden), ""); + } + } + } + private void add_group(SensorKind kind, string title) { bool any = false; foreach (SensorReading reading in monitor.readings()) { @@ -449,6 +650,8 @@ namespace Singularity { add_group(SensorKind.BOARD, _("Board")); add_group(SensorKind.SYSTEM, _("System")); + add_utilization_details(); + // Clocks are NOT colour-coded. A core at its maximum is doing its // job, not overheating, and painting it red would train the user to // ignore the colour that does mean something. They are shown diff --git a/src/core/system_monitor.vala b/src/core/system_monitor.vala index 6f50d37..1373829 100644 --- a/src/core/system_monitor.vala +++ b/src/core/system_monitor.vala @@ -43,6 +43,23 @@ namespace Singularity { * topology, where no chip or label contains "TZ", so one configuration * serves both kernels. */ + /** + * Live CPU / memory / disk utilisation. + * + * Separate from `sensors` because it needs start/stop for CORRECTNESS, + * not merely to save power: every figure but memory is a rate computed + * between two samples, so a monitor left running while nothing reads + * it is measuring a window no one asked about. + */ + public UtilizationMonitor utilization { + get { + if (_utilization == null) { + _utilization = new UtilizationMonitor(); + } + return _utilization; + } + } + public SensorMonitor sensors { get { if (_sensors == null) { @@ -142,6 +159,7 @@ namespace Singularity { private PowerProfilesManager? _power_profiles; private ResourceMonitor? _resources; private SensorMonitor? _sensors = null; + private UtilizationMonitor? _utilization = null; private CallMonitor? _call_monitor; public static SystemMonitor get_default() { From 19becc3b09f1e0d8bec4e21ae197e71389c4308c Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Wed, 19 Aug 2026 10:57:19 -0400 Subject: [PATCH 15/21] Prime the utilization poll before the first visibility decision utilization_available() probes memory_fraction, which is -1.0 until something has polled. Starting the poller only from the map handler rebuilt the self-deadlocking initialisation the surrounding comment already warns about, one step further out: on a machine with no readable hwmon but a working /proc -- a VM with no thermal zones, or any board without an hwmon driver -- monitor.available is false and utilization_available() is false only because nothing has looked yet. on_updated() hides the widget, GTK never maps an invisible widget, the map handler never fires, util.start() never runs, and the indicator stays hidden for the whole session with CPU and memory figures it could have shown from the start. One synchronous poll before that first decision, mirroring the monitor.refresh() immediately above it. Found by the local upstream-reviewer simulation (sing-presubmit-review) before this was pushed, which is what that tool exists for: the same finding from the real chatgpt-codex-connector bot would have cost a PR round-trip. --- src/components/panel/panel.vala | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/components/panel/panel.vala b/src/components/panel/panel.vala index 1cc7daf..2d7a0c9 100644 --- a/src/components/panel/panel.vala +++ b/src/components/panel/panel.vala @@ -141,6 +141,21 @@ namespace Singularity { // establishes real availability before that first visibility // decision, so a fresh shell doesn't self-hide permanently. monitor.refresh(); + // Prime utilisation for the SAME reason, and BEFORE the first + // visibility decision below. + // + // utilization_available() probes memory_fraction, which is -1.0 + // until something has polled. Leaving that to the map handler + // rebuilds the exact deadlock the paragraph above describes, one + // step further out: on a machine with no readable hwmon but a + // perfectly good /proc -- a VM with no thermal zones, or any of + // the many boards without an hwmon driver -- monitor.available is + // false and utilization_available() is false only because nothing + // has looked yet. on_updated() hides the widget, GTK never maps + // it, util.start() never runs, and the indicator stays hidden for + // the life of the session with CPU and memory figures it could + // have shown all along. + if (show_utilization) util.poll(); on_updated(); // Poll only while actually on screen. An unmapped or hidden panel From 4f254ee741ee00a6a7f3096fb4feac00e31c5050 Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Wed, 19 Aug 2026 13:21:16 -0400 Subject: [PATCH 16/21] Colorize the compact sensors chip and use Pango markup Each metric in the compact chip now carries its own colour via Pango markup spans instead of one plain-text label with a whole-chip severity class: temperature and memory by their own severity (green/amber/red at the same thresholds the popover already uses), CPU and clock speed a neutral accent colour since neither is ever severity-coloured -- a core at 100% is doing its job, the same reasoning the popover's Clocks section documents. Colours are resolved at render time via Widget.lookup_color() against named theme tokens (success_color/warning_color/error_color/ accent_color), not hardcoded hex, so the chip tracks a light/dark theme switch instead of baking in whichever mode happened to be active when this was written. Checked for icon availability before reaching for icons instead: neither Adwaita nor the NCZ icon theme has a CPU, memory, or temperature symbolic icon (only breeze/breeze-dark do, and Adwaita is the active theme) -- forcing a mismatched or missing glyph would be worse than the plain numbers. A small coloured dot (U+25CF) per segment gives the same glanceable, graphical read without that risk. --- src/components/panel/panel.vala | 99 +++++++++++++++++++++++++++------ 1 file changed, 81 insertions(+), 18 deletions(-) diff --git a/src/components/panel/panel.vala b/src/components/panel/panel.vala index 2d7a0c9..87d11e3 100644 --- a/src/components/panel/panel.vala +++ b/src/components/panel/panel.vala @@ -41,6 +41,12 @@ namespace Singularity { summary_label = new Label(""); summary_label.add_css_class("sensors-summary"); + // Pango markup, not plain text: the compact chip colours each + // metric's dot + value independently (temperature by thermal + // severity, memory by capacity, CPU/frequency neutral) so a + // glance shows WHICH figure needs attention, not just that one + // does. + summary_label.use_markup = true; button = new MenuButton(); button.add_css_class("flat"); @@ -211,6 +217,36 @@ namespace Singularity { return show_utilization && util.memory_fraction >= 0.0; } + /** + * Resolve a NAMED theme colour (e.g. "success_color") to a hex + * string for Pango markup. + * + * Markup spans take a literal colour, not a CSS variable, so the + * value has to be looked up at render time rather than written once + * -- this is what keeps it honest across a light/dark theme switch + * instead of baking in a colour that only happened to be right when + * the code was written. Falls back to the theme's plain text colour + * if the named token is ever missing, so a lookup failure degrades + * to unstyled text rather than invalid markup. + */ + private string theme_color_hex(string color_name) { + Gdk.RGBA rgba; + if (!summary_label.lookup_color(color_name, out rgba)) { + if (!summary_label.lookup_color("text_color", out rgba)) { + return "#ffffff"; + } + } + return "#%02x%02x%02x".printf( + (uint) Math.round(rgba.red * 255), + (uint) Math.round(rgba.green * 255), + (uint) Math.round(rgba.blue * 255)); + } + + /** One coloured "dot value" segment for the compact chip. */ + private string markup_segment(string color_hex, string text) { + return "\u25cf %s".printf(color_hex, Markup.escape_text(text)); + } + private static int percent_of(double fraction) { int p = (int) Math.round(fraction * 100.0); if (p < 0) return 0; @@ -325,25 +361,25 @@ namespace Singularity { break; } } - // Drop whatever the last tick set before setting this one: - // add_css_class is additive, so an unremoved "error" would stay - // red for the rest of the session once the machine had been hot. + // Drop the whole-label severity class the old plain-text chip + // used: each metric below now carries its OWN colour via + // markup, which is strictly more informative (which figure is + // hot, not just that something is) and would otherwise fight + // the per-segment colours for the eye. summary_label.remove_css_class("warning"); summary_label.remove_css_class("error"); - string? summary_css = severity_css(primary_severity); - if (summary_css != null && summary_css != "dim-label") { - summary_label.add_css_class(summary_css); - } - StringBuilder text = new StringBuilder(); + StringBuilder markup = new StringBuilder(); if (primary >= 0) { - text.append(format_celsius(primary)); + markup.append(markup_segment(theme_color_hex(severity_color_name(primary_severity)), + format_celsius(primary))); } if (show_frequency && monitor.cpu_khz > 0) { - if (text.len > 0) { - text.append(" · "); - } - text.append(format_clock(monitor.cpu_khz)); + if (markup.len > 0) markup.append(" "); + // Clock speed is informational, never an alarm colour -- + // same reasoning as CPU below: running near the maximum is + // the CPU doing its job, not a problem to flag red. + markup.append(markup_segment(theme_color_hex("accent_color"), format_clock(monitor.cpu_khz))); } // Utilisation in the compact chip, not only in the popover. // @@ -354,15 +390,23 @@ namespace Singularity { // has no swap. if (show_utilization) { if (util.cpu_fraction >= 0.0) { - if (text.len > 0) text.append(" \u00b7 "); - text.append(_("CPU %d%%").printf(percent_of(util.cpu_fraction))); + if (markup.len > 0) markup.append(" "); + // CPU busy is never severity-coloured: a core at 100% is + // doing its job, and painting that red would train the + // user to ignore the colour that does mean something -- + // the same reasoning the popover's Clocks section and + // capacity_severity() already document. + markup.append(markup_segment(theme_color_hex("accent_color"), + _("CPU %d%%").printf(percent_of(util.cpu_fraction)))); } if (util.memory_fraction >= 0.0) { - if (text.len > 0) text.append(" \u00b7 "); - text.append(_("MEM %d%%").printf(percent_of(util.memory_fraction))); + if (markup.len > 0) markup.append(" "); + Severity mem_severity = capacity_severity(util.memory_fraction); + markup.append(markup_segment(theme_color_hex(severity_color_name(mem_severity)), + _("MEM %d%%").printf(percent_of(util.memory_fraction)))); } } - summary_label.label = text.str; + summary_label.label = markup.str; Popover? popover = button.popover; if (popover != null && popover.visible) { @@ -392,6 +436,25 @@ namespace Singularity { * the first step of the ramp. Colour is spent only where it means * something: dim, plain, amber, red. */ + /** + * Severity -> a named theme colour, for markup (not a CSS class). + * + * NORMAL reads as success (a calm "this is fine" green) rather than + * plain text, matching the standard status-dashboard convention the + * graphical chip is going for. WARM stays neutral -- the original + * design's severity_css() below also treats WARM as not yet worth + * flagging, and this mirrors that rather than inventing a new + * threshold. + */ + private string severity_color_name(Severity severity) { + switch (severity) { + case Severity.CRITICAL: return "error_color"; + case Severity.HOT: return "warning_color"; + case Severity.WARM: return "text_color"; + default: return "success_color"; + } + } + private static string? severity_css(Severity severity) { switch (severity) { case Severity.CRITICAL: return "error"; From e5527b103d117cf7b026c68c63ee8127ea0dab62 Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Wed, 19 Aug 2026 13:32:29 -0400 Subject: [PATCH 17/21] Fix build: lookup_color lives on StyleContext, not Widget Compile error caught by the container build: `lookup_color` is a Gtk.StyleContext member (deprecated since 4.10 but still the working path -- there is no non-deprecated replacement for resolving a NAMED CSS colour at runtime), not a Gtk.Widget method directly. Route through get_style_context() instead of calling it on the label. --- src/components/panel/panel.vala | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/components/panel/panel.vala b/src/components/panel/panel.vala index 87d11e3..ce31600 100644 --- a/src/components/panel/panel.vala +++ b/src/components/panel/panel.vala @@ -230,9 +230,15 @@ namespace Singularity { * to unstyled text rather than invalid markup. */ private string theme_color_hex(string color_name) { + // lookup_color lives on StyleContext, not on Widget directly + // (deprecated since GTK 4.10, but still the working path -- no + // non-deprecated replacement exists for resolving a NAMED CSS + // colour at runtime, only get_color() for the resolved `color` + // property itself). + var style = summary_label.get_style_context(); Gdk.RGBA rgba; - if (!summary_label.lookup_color(color_name, out rgba)) { - if (!summary_label.lookup_color("text_color", out rgba)) { + if (!style.lookup_color(color_name, out rgba)) { + if (!style.lookup_color("text_color", out rgba)) { return "#ffffff"; } } From 019811351ebb80aecae5b77c4843e90ec019a581 Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Wed, 19 Aug 2026 13:47:13 -0400 Subject: [PATCH 18/21] Group CPU clocks by performance tier, average every overflow row Two related changes to the popover's grouped sections: 1. Clocks no longer lists cpufreq policies 1:1 as generic "Core group N" rows capped at MAX_ROWS_PER_GROUP with a bare count for the rest. Grouped by max_khz instead -- the actual performance-tier signal: clocks() is one entry per cpufreq POLICY, and a policy is a clock domain, so cores sharing one on a heterogeneous SoC (Sky1's five policies) are exactly the cores in the same tier. This is architecture-agnostic by construction, not by special-casing: a homogeneous desktop CPU collapses to one row (every core reports the same max), while a hybrid P-core/E-core x86 or ARM big.LITTLE part produces multiple tier rows from the identical logic. The "N more" cap this replaces was only ever a symptom of not doing this grouping to begin with. 2. Every remaining overflow row (CPU per-core, disk activity, and each temperature SensorKind group) now shows the AVERAGE of what got cut instead of a bare count with an empty value -- a 64-core machine still tells you roughly how busy the other 58 cores are. Storage capacity is the one deliberate exception: volume fill percentages across differently-sized disks don't average into a meaningful number the way a rate does, so it keeps a plain count and gets its own separate overflow counter rather than being blended with disk activity's rate-based average. Temperature's averaged row is deliberately left uncoloured: severity is classified per-sensor against that sensor's own limit, and an average across sensors that may have different limits has no single threshold to colour against. --- src/components/panel/panel.vala | 110 ++++++++++++++++++++++++++------ 1 file changed, 90 insertions(+), 20 deletions(-) diff --git a/src/components/panel/panel.vala b/src/components/panel/panel.vala index ce31600..a363a03 100644 --- a/src/components/panel/panel.vala +++ b/src/components/panel/panel.vala @@ -592,6 +592,7 @@ namespace Singularity { // it. int shown = 0; int hidden = 0; + double hidden_sum = 0.0; foreach (UtilizationReading core in cores) { if (core.fraction < 0.0) { continue; // first sample: no rate yet @@ -602,10 +603,16 @@ namespace Singularity { shown++; } else { hidden++; + hidden_sum += core.fraction; } } + // The overflow row shows the AVERAGE of what got cut, not + // just a count with no data in it -- a machine with 64 cores + // still tells you roughly how busy the other 58 are, instead + // of discarding that information entirely. if (hidden > 0) { - add_row(_("%d more").printf(hidden), ""); + add_row(_("%d more").printf(hidden), + "%d%%".printf(percent_of(hidden_sum / hidden))); } } @@ -634,7 +641,14 @@ namespace Singularity { add_heading(_("Storage")); int shown = 0; - int hidden = 0; + // Capacity and activity get SEPARATE overflow counters, not + // one shared one: they are different quantities (a fill + // level vs a busy rate) and averaging them together, or + // averaging capacity % across differently-sized volumes, + // would blend numbers that don't mean the same thing. Only + // the activity overflow gets an average -- it is a rate, + // the same class of number CPU busy already is. + int hidden_volumes = 0; foreach (CapacityReading vol in volumes) { if (vol.fraction < 0.0) { continue; @@ -646,14 +660,19 @@ namespace Singularity { capacity_severity(vol.fraction), vol.fraction); shown++; } else { - hidden++; + hidden_volumes++; } } + if (hidden_volumes > 0) { + add_row(_("%d more").printf(hidden_volumes), ""); + } // Busy percentage is a RATE, not a fill level, so it is listed // after capacity and left uncoloured -- a disk at 100% busy is // working, a disk at 100% full is broken, and they must not // look alike. + int hidden_disks = 0; + double hidden_disk_sum = 0.0; foreach (UtilizationReading disk in spindles) { if (disk.fraction < 0.0) { continue; @@ -664,11 +683,13 @@ namespace Singularity { Severity.NORMAL, disk.fraction); shown++; } else { - hidden++; + hidden_disks++; + hidden_disk_sum += disk.fraction; } } - if (hidden > 0) { - add_row(_("%d more").printf(hidden), ""); + if (hidden_disks > 0) { + add_row(_("%d more").printf(hidden_disks), + "%d%%".printf(percent_of(hidden_disk_sum / hidden_disks))); } } } @@ -691,6 +712,7 @@ namespace Singularity { // so show the first few and state how many were left out. int shown = 0; int hidden = 0; + int64 hidden_millidegrees_sum = 0; foreach (SensorReading reading in monitor.readings()) { if (reading.kind != kind) { continue; @@ -701,10 +723,18 @@ namespace Singularity { shown++; } else { hidden++; + hidden_millidegrees_sum += reading.millidegrees; } } + // The overflow row shows the AVERAGE temperature of what got + // cut, not just a count with no data in it -- same reasoning as + // the per-core and per-disk overflow rows below. Deliberately + // uncoloured: severity is classified per-sensor against that + // sensor's own limit, and averaging across sensors that may have + // different limits has no single threshold to colour against. if (hidden > 0) { - add_row(_("%d more").printf(hidden), ""); + add_row(_("%d more").printf(hidden), + format_celsius((int) (hidden_millidegrees_sum / hidden))); } } @@ -750,20 +780,60 @@ namespace Singularity { ClockReading[] clocks = show_frequency ? monitor.clocks() : new ClockReading[0]; if (clocks.length > 0) { add_heading(_("Clocks")); - // Same cap-and-count convention as add_group() above: a - // per-CPU cpufreq policy (one entry per core on some x86 - // layouts) can run past a hundred, and the popover has no - // scroll container, so an uncapped list grows off-screen. - int shown = int.min(clocks.length, MAX_ROWS_PER_GROUP); - for (int i = 0; i < shown; i++) { - string value = clocks[i].max_khz > 0 - ? "%s / %s".printf(format_clock(clocks[i].khz), - format_clock(clocks[i].max_khz)) - : format_clock(clocks[i].khz); - add_row(_("Core group %d").printf(i + 1), value); + // Group by max_khz -- the actual performance-tier signal. + // clocks() is one entry per cpufreq POLICY, and a policy is a + // clock domain: cores sharing one on a heterogeneous SoC + // (Sky1's five policies) are exactly the cores in the same + // tier, so an equal max_khz reliably identifies "same tier" + // without needing core-type names the backend doesn't have. + // On a homogeneous desktop CPU where every core reports the + // same max, this collapses a hundred identical rows into + // one -- the "N more" cap this replaced was only ever a + // symptom of not doing this grouping in the first place. + // + // Plain parallel arrays + linear scan rather than a Gee map: + // tier count is always small (Sky1 has 5 policies at most), + // so the O(n*tiers) scan costs nothing, and it avoids any + // uncertainty about Gee's generic-boxing behaviour for a + // primitive int key. + int[] tier_max = {}; + int64[] tier_khz_sum = {}; + int[] tier_count = {}; + foreach (ClockReading c in clocks) { + int idx = -1; + for (int i = 0; i < tier_max.length; i++) { + if (tier_max[i] == c.max_khz) { idx = i; break; } + } + if (idx < 0) { + tier_max += c.max_khz; + tier_khz_sum += (int64) c.khz; + tier_count += 1; + } else { + tier_khz_sum[idx] += c.khz; + tier_count[idx] += 1; + } + } + // Fastest tier first: the one most people check first, and + // matches how the sensor groups above already read + // hottest-first. + for (int i = 0; i < tier_max.length; i++) { + for (int j = i + 1; j < tier_max.length; j++) { + if (tier_max[j] > tier_max[i]) { + int tmp_max = tier_max[i]; tier_max[i] = tier_max[j]; tier_max[j] = tmp_max; + int64 tmp_sum = tier_khz_sum[i]; tier_khz_sum[i] = tier_khz_sum[j]; tier_khz_sum[j] = tmp_sum; + int tmp_cnt = tier_count[i]; tier_count[i] = tier_count[j]; tier_count[j] = tmp_cnt; + } + } } - if (clocks.length > shown) { - add_row(_("%d more").printf(clocks.length - shown), ""); + for (int i = 0; i < tier_max.length; i++) { + int avg_khz = (int) (tier_khz_sum[i] / tier_count[i]); + string label = tier_count[i] > 1 + ? _("%d cores").printf(tier_count[i]) + : _("1 core"); + string value = tier_max[i] > 0 + ? "%s / %s".printf(format_clock(avg_khz), format_clock(tier_max[i])) + : format_clock(avg_khz); + add_row(label, value); } } } From 4c387ff87cdf87c572405667af5dfb3f422dfd04 Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Wed, 19 Aug 2026 14:21:55 -0400 Subject: [PATCH 19/21] Add a Grouped/Ungrouped toggle to the Clocks section Measured against O6N's real cpufreq topology before building this: Sky1's five policies each report a genuinely DIFFERENT max_khz (2.6, 1.8, 2.3, 2.2, 2.5 GHz across policy0/2/6/8/10), so the tier-grouping added earlier produces the same five rows the old per-policy listing did -- nothing collapses on this hardware, because nothing shares a ceiling to collapse into. That made the grouping look like it had no effect, when it was working correctly for a case this board doesn't exhibit. The toggle makes the difference checkable rather than assumed: flip to Ungrouped and the exact per-policy rows are there, labelled with the real sysfs policy name (e.g. "policy0"), so grouped-vs-raw can be compared directly instead of trusted on faith. It also gives the grouping real value on hardware where it DOES matter -- a homogeneous desktop CPU, or same-tier cores on a hybrid part -- without forcing that choice for everyone. Persisted via sensors-clocks-grouped (same has_key-guarded read/write pattern as the other sensors-* prefs), but the control itself lives in the popover next to the Clocks heading, not a hidden setting -- you should be able to see it and flip it right where the numbers are. --- src/components/panel/panel.vala | 178 +++++++++++++++++++++++--------- 1 file changed, 127 insertions(+), 51 deletions(-) diff --git a/src/components/panel/panel.vala b/src/components/panel/panel.vala index a363a03..8e7b3fe 100644 --- a/src/components/panel/panel.vala +++ b/src/components/panel/panel.vala @@ -28,16 +28,32 @@ namespace Singularity { private SensorMonitor monitor; private bool show_frequency = true; private bool show_utilization = true; + // Whether Clocks groups cpufreq policies that share an EXACT max_khz + // into one row, or lists every policy raw. Defaults to grouped. + // Persisted so the choice survives a popover close/reopen, but the + // toggle itself lives in the popover (see rebuild_details()), not a + // settings page -- see the operator's own reasoning: Sky1's five + // policies happen to have five DIFFERENT ceilings, so grouped and + // ungrouped render almost identically there; the toggle matters on + // hardware where policies genuinely share a ceiling (a homogeneous + // desktop CPU, or same-tier cores on a hybrid part) and collapsing + // is worth seeing happen, or worth turning off to inspect per-policy. + private bool clocks_grouped = true; private UtilizationMonitor util; // Set when on_updated() hides the chip because no sensors are // readable, so the unmap handler can tell a self-inflicted unmap // (must keep polling, or recovery is never observed) from a real one. private bool hidden_for_unavailable = false; + // Kept as a field (the constructor previously only took it as a local + // parameter) so the Clocks group/ungroup toggle can write the + // preference back when clicked, not just read it once at construct. + private GLib.Settings settings; public SensorsIndicator(GLib.Settings settings) { Object(orientation: Orientation.HORIZONTAL, spacing: 0); valign = Align.CENTER; add_css_class("sensors-indicator"); + this.settings = settings; summary_label = new Label(""); summary_label.add_css_class("sensors-summary"); @@ -120,6 +136,9 @@ namespace Singularity { if (schema != null && schema.has_key("sensors-show-utilization")) { show_utilization = settings.get_boolean("sensors-show-utilization"); } + if (schema != null && schema.has_key("sensors-clocks-grouped")) { + clocks_grouped = settings.get_boolean("sensors-clocks-grouped"); + } // Only override when the user has actually configured a zone name. // The schema's portable default for these keys is an empty string, // and monitor.gpu_hint/cpu_hint already carry the platform-specific @@ -428,6 +447,42 @@ namespace Singularity { detail_box.append(heading); } + /** + * "Clocks" heading with a clickable Grouped/Ungrouped toggle. + * + * The label doubles as the current state, not just an action verb + * ("Grouped" / "Ungrouped"), so glancing at it tells you which mode + * you are already in -- an action-only "Group"/"Ungroup" button + * would require remembering what you last clicked. + */ + private void add_clocks_heading() { + Box row = new Box(Orientation.HORIZONTAL, 6); + row.margin_top = 4; + + Label heading = new Label(_("Clocks")); + heading.add_css_class("heading"); + heading.halign = Align.START; + heading.hexpand = true; + row.append(heading); + + Button toggle = new Button(); + toggle.has_frame = false; + toggle.add_css_class("flat"); + toggle.add_css_class("dim-label"); + toggle.label = clocks_grouped ? _("Grouped") : _("Ungrouped"); + toggle.clicked.connect(() => { + clocks_grouped = !clocks_grouped; + SettingsSchema? schema = settings.settings_schema; + if (schema != null && schema.has_key("sensors-clocks-grouped")) { + settings.set_boolean("sensors-clocks-grouped", clocks_grouped); + } + rebuild_details(); + }); + row.append(toggle); + + detail_box.append(row); + } + /** * CSS class for a severity, or null to leave the label unstyled. * @@ -779,62 +834,83 @@ namespace Singularity { // opened -- the preference silently did half of what it says. ClockReading[] clocks = show_frequency ? monitor.clocks() : new ClockReading[0]; if (clocks.length > 0) { - add_heading(_("Clocks")); - // Group by max_khz -- the actual performance-tier signal. - // clocks() is one entry per cpufreq POLICY, and a policy is a - // clock domain: cores sharing one on a heterogeneous SoC - // (Sky1's five policies) are exactly the cores in the same - // tier, so an equal max_khz reliably identifies "same tier" - // without needing core-type names the backend doesn't have. - // On a homogeneous desktop CPU where every core reports the - // same max, this collapses a hundred identical rows into - // one -- the "N more" cap this replaced was only ever a - // symptom of not doing this grouping in the first place. - // - // Plain parallel arrays + linear scan rather than a Gee map: - // tier count is always small (Sky1 has 5 policies at most), - // so the O(n*tiers) scan costs nothing, and it avoids any - // uncertainty about Gee's generic-boxing behaviour for a - // primitive int key. - int[] tier_max = {}; - int64[] tier_khz_sum = {}; - int[] tier_count = {}; - foreach (ClockReading c in clocks) { - int idx = -1; + add_clocks_heading(); + + if (clocks_grouped) { + // Group by max_khz -- the actual performance-tier + // signal. clocks() is one entry per cpufreq POLICY, and + // a policy is a clock domain: cores sharing one on a + // heterogeneous SoC (Sky1's five policies) are exactly + // the cores in the same tier, so an equal max_khz + // reliably identifies "same tier" without needing + // core-type names the backend doesn't have. On a + // homogeneous desktop CPU where every core reports the + // same max, this collapses a hundred identical rows + // into one. On Sky1 specifically every policy happens + // to have a DIFFERENT ceiling, so grouped and ungrouped + // render almost identically there -- the toggle below + // exists so that is verifiable rather than assumed, and + // so it still collapses rows on hardware where policies + // genuinely do share a ceiling. + // + // Plain parallel arrays + linear scan rather than a Gee + // map: tier count is always small (Sky1 has 5 policies + // at most), so the O(n*tiers) scan costs nothing, and it + // avoids any uncertainty about Gee's generic-boxing + // behaviour for a primitive int key. + int[] tier_max = {}; + int64[] tier_khz_sum = {}; + int[] tier_count = {}; + foreach (ClockReading c in clocks) { + int idx = -1; + for (int i = 0; i < tier_max.length; i++) { + if (tier_max[i] == c.max_khz) { idx = i; break; } + } + if (idx < 0) { + tier_max += c.max_khz; + tier_khz_sum += (int64) c.khz; + tier_count += 1; + } else { + tier_khz_sum[idx] += c.khz; + tier_count[idx] += 1; + } + } + // Fastest tier first: the one most people check first, + // and matches how the sensor groups above already read + // hottest-first. for (int i = 0; i < tier_max.length; i++) { - if (tier_max[i] == c.max_khz) { idx = i; break; } + for (int j = i + 1; j < tier_max.length; j++) { + if (tier_max[j] > tier_max[i]) { + int tmp_max = tier_max[i]; tier_max[i] = tier_max[j]; tier_max[j] = tmp_max; + int64 tmp_sum = tier_khz_sum[i]; tier_khz_sum[i] = tier_khz_sum[j]; tier_khz_sum[j] = tmp_sum; + int tmp_cnt = tier_count[i]; tier_count[i] = tier_count[j]; tier_count[j] = tmp_cnt; + } + } } - if (idx < 0) { - tier_max += c.max_khz; - tier_khz_sum += (int64) c.khz; - tier_count += 1; - } else { - tier_khz_sum[idx] += c.khz; - tier_count[idx] += 1; + for (int i = 0; i < tier_max.length; i++) { + int avg_khz = (int) (tier_khz_sum[i] / tier_count[i]); + string label = tier_count[i] > 1 + ? _("%d cores").printf(tier_count[i]) + : _("1 core"); + string value = tier_max[i] > 0 + ? "%s / %s".printf(format_clock(avg_khz), format_clock(tier_max[i])) + : format_clock(avg_khz); + add_row(label, value); } - } - // Fastest tier first: the one most people check first, and - // matches how the sensor groups above already read - // hottest-first. - for (int i = 0; i < tier_max.length; i++) { - for (int j = i + 1; j < tier_max.length; j++) { - if (tier_max[j] > tier_max[i]) { - int tmp_max = tier_max[i]; tier_max[i] = tier_max[j]; tier_max[j] = tmp_max; - int64 tmp_sum = tier_khz_sum[i]; tier_khz_sum[i] = tier_khz_sum[j]; tier_khz_sum[j] = tmp_sum; - int tmp_cnt = tier_count[i]; tier_count[i] = tier_count[j]; tier_count[j] = tmp_cnt; - } + } else { + // Raw, one row per cpufreq policy, in whatever order + // clocks() returned them -- no grouping, no averaging. + // The label is the policy's own sysfs directory name + // (e.g. "policy0"), the same identifier a person would + // see if they went and looked at + // /sys/devices/system/cpu/cpufreq/ themselves. + foreach (ClockReading c in clocks) { + string value = c.max_khz > 0 + ? "%s / %s".printf(format_clock(c.khz), format_clock(c.max_khz)) + : format_clock(c.khz); + add_row(c.label, value); } } - for (int i = 0; i < tier_max.length; i++) { - int avg_khz = (int) (tier_khz_sum[i] / tier_count[i]); - string label = tier_count[i] > 1 - ? _("%d cores").printf(tier_count[i]) - : _("1 core"); - string value = tier_max[i] > 0 - ? "%s / %s".printf(format_clock(avg_khz), format_clock(tier_max[i])) - : format_clock(avg_khz); - add_row(label, value); - } } } } From d157ba8efac5f3699457985b0505d45f3fbb1554 Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Thu, 20 Aug 2026 01:39:48 +0000 Subject: [PATCH 20/21] panel: move the sensors Grouped/Ungrouped toggle to the top, apply it to every group The toggle previously lived at the Clocks heading and only affected Clocks tier-grouping. Renamed clocks_grouped -> sensors_grouped (and the settings key sensors-clocks-grouped -> sensors-grouped to match) and had add_group() skip its per-kind heading when ungrouped, sharing the same field -- so one toggle now governs every section (CPU/GPU/NPU/Memory/Storage/Network/Board/ System/Clocks), not just Clocks. Moved the control itself to the top of rebuild_details(), before any section renders, so its popover-wide scope is visible from where it sits rather than looking like a Clocks-only control. Clocks keeps its own plain heading (add_heading), shown only when grouped, consistent with every other section. Verified: rebuilt via build-singularity.sh against this checkout, deployed to O6N, rebooted clean, desktop session running. --- src/components/panel/panel.vala | 65 +++++++++++++++++++++------------ 1 file changed, 42 insertions(+), 23 deletions(-) diff --git a/src/components/panel/panel.vala b/src/components/panel/panel.vala index 8e7b3fe..4ee4a7c 100644 --- a/src/components/panel/panel.vala +++ b/src/components/panel/panel.vala @@ -28,17 +28,21 @@ namespace Singularity { private SensorMonitor monitor; private bool show_frequency = true; private bool show_utilization = true; - // Whether Clocks groups cpufreq policies that share an EXACT max_khz - // into one row, or lists every policy raw. Defaults to grouped. - // Persisted so the choice survives a popover close/reopen, but the - // toggle itself lives in the popover (see rebuild_details()), not a - // settings page -- see the operator's own reasoning: Sky1's five - // policies happen to have five DIFFERENT ceilings, so grouped and - // ungrouped render almost identically there; the toggle matters on - // hardware where policies genuinely share a ceiling (a homogeneous - // desktop CPU, or same-tier cores on a hybrid part) and collapsing - // is worth seeing happen, or worth turning off to inspect per-policy. - private bool clocks_grouped = true; + // Controls TWO things, both driven by the single toggle in the + // popover (see add_sensors_toggle()/rebuild_details()): + // 1. Whether the per-kind sections (CPU/GPU/NPU/Memory/... from + // add_group()) render their heading label, or flatten into one + // unheaded list. + // 2. Whether Clocks groups cpufreq policies that share an EXACT + // max_khz into one row, or lists every policy raw. + // Defaults to grouped. Persisted so the choice survives a popover + // close/reopen. On Clocks specifically, Sky1's five policies happen + // to have five DIFFERENT ceilings, so grouped and ungrouped render + // almost identically there; the toggle matters on hardware where + // policies genuinely share a ceiling (a homogeneous desktop CPU, or + // same-tier cores on a hybrid part) and collapsing is worth seeing + // happen, or worth turning off to inspect per-policy. + private bool sensors_grouped = true; private UtilizationMonitor util; // Set when on_updated() hides the chip because no sensors are // readable, so the unmap handler can tell a self-inflicted unmap @@ -136,8 +140,8 @@ namespace Singularity { if (schema != null && schema.has_key("sensors-show-utilization")) { show_utilization = settings.get_boolean("sensors-show-utilization"); } - if (schema != null && schema.has_key("sensors-clocks-grouped")) { - clocks_grouped = settings.get_boolean("sensors-clocks-grouped"); + if (schema != null && schema.has_key("sensors-grouped")) { + sensors_grouped = settings.get_boolean("sensors-grouped"); } // Only override when the user has actually configured a zone name. // The schema's portable default for these keys is an empty string, @@ -448,18 +452,20 @@ namespace Singularity { } /** - * "Clocks" heading with a clickable Grouped/Ungrouped toggle. + * Popover-wide Grouped/Ungrouped toggle. Rendered first, before any + * sensor section, so its scope (every group below, not just one + * subsection) is visible from where it sits. * * The label doubles as the current state, not just an action verb * ("Grouped" / "Ungrouped"), so glancing at it tells you which mode * you are already in -- an action-only "Group"/"Ungroup" button * would require remembering what you last clicked. */ - private void add_clocks_heading() { + private void add_sensors_toggle() { Box row = new Box(Orientation.HORIZONTAL, 6); row.margin_top = 4; - Label heading = new Label(_("Clocks")); + Label heading = new Label(_("Sensors")); heading.add_css_class("heading"); heading.halign = Align.START; heading.hexpand = true; @@ -469,12 +475,12 @@ namespace Singularity { toggle.has_frame = false; toggle.add_css_class("flat"); toggle.add_css_class("dim-label"); - toggle.label = clocks_grouped ? _("Grouped") : _("Ungrouped"); + toggle.label = sensors_grouped ? _("Grouped") : _("Ungrouped"); toggle.clicked.connect(() => { - clocks_grouped = !clocks_grouped; + sensors_grouped = !sensors_grouped; SettingsSchema? schema = settings.settings_schema; - if (schema != null && schema.has_key("sensors-clocks-grouped")) { - settings.set_boolean("sensors-clocks-grouped", clocks_grouped); + if (schema != null && schema.has_key("sensors-grouped")) { + settings.set_boolean("sensors-grouped", sensors_grouped); } rebuild_details(); }); @@ -760,7 +766,13 @@ namespace Singularity { if (!any) { return; } - add_heading(title); + // Ungrouped flattens the list by hiding the per-kind heading; + // the rows themselves (and their MAX_ROWS_PER_GROUP cap / overflow + // averaging below) are unchanged, same field the Clocks section + // toggles -- see sensors_grouped's declaration. + if (sensors_grouped) { + add_heading(title); + } // Cap the rows. Sensor count varies enormously by platform: an ARM // dev board reports 5, a Qualcomm SC8280XP reports 55. Listing all // of them turns the popover into a wall of near-identical numbers, @@ -801,6 +813,11 @@ namespace Singularity { child = detail_box.get_first_child(); } + // One control for the whole popover, at the top so its scope is + // obvious before any section renders: it decides whether every + // group below (CPU/GPU/NPU/... and Clocks) shows its heading. + add_sensors_toggle(); + // Every kind the backend can name, hottest-silicon first and the // board last. add_group() skips a kind with no sensors, so a PC // that reports only CPU and GPU still shows exactly two headings. @@ -834,9 +851,11 @@ namespace Singularity { // opened -- the preference silently did half of what it says. ClockReading[] clocks = show_frequency ? monitor.clocks() : new ClockReading[0]; if (clocks.length > 0) { - add_clocks_heading(); + if (sensors_grouped) { + add_heading(_("Clocks")); + } - if (clocks_grouped) { + if (sensors_grouped) { // Group by max_khz -- the actual performance-tier // signal. clocks() is one entry per cpufreq POLICY, and // a policy is a clock domain: cores sharing one on a From 19e66620bdc399444279fc526228eafde7675402 Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Thu, 20 Aug 2026 01:54:18 +0000 Subject: [PATCH 21/21] panel: collapse each sensor family to one aggregate row when grouped, merge CPU+Clocks into one section Grouped previously only hid per-kind headings while still listing every individual sensor -- it never actually collapsed anything, so toggling Grouped/Ungrouped looked like it did nothing on most sections. Rewired add_group() so Grouped now renders exactly ONE row per kind (GPU, VPU, NPU, Memory, Storage, Network, Board, System), averaging every reading of that kind; Ungrouped is unchanged (heading + one row per sensor + capped overflow-average row). CPU and Clocks were also two disconnected top-level sections describing the same silicon. Merged them into one add_cpu_section(): Grouped now shows one aggregate CPU temperature row plus one row per cpufreq cluster (existing max_khz tier-grouping, unchanged algorithm -- verified on O6N this already equals real topology: cluster_id sysfs gives 5 clusters mapping exactly 1:1 onto cpufreq policy0/2/6/8/10). Ungrouped shows the same merge but with every individual temperature sensor and every individual cpufreq policy listed under one CPU heading instead of two. Deliberately did NOT attach a per-cluster temperature to each clock row: CIX Sky1 only defines 4 named thermal zones (CPU_B0/B1, CPU_M0/M1 -- exhaustively verified against every scmi_sensors hwmon label, 21 total sensors, no 5th CPU zone) against 5 cpufreq clusters. The two do not partition the cores the same way and nothing in sysfs links a named thermal zone to the core numbers it measures, so a per-cluster temperature would be fabricated, not measured. Verified: rebuilt via build-singularity.sh against this checkout, deployed to O6N, rebooted clean, greeter healthy. --- src/components/panel/panel.vala | 266 +++++++++++++++++++------------- 1 file changed, 158 insertions(+), 108 deletions(-) diff --git a/src/components/panel/panel.vala b/src/components/panel/panel.vala index 4ee4a7c..0992d7f 100644 --- a/src/components/panel/panel.vala +++ b/src/components/panel/panel.vala @@ -756,23 +756,36 @@ namespace Singularity { } private void add_group(SensorKind kind, string title) { - bool any = false; + SensorReading[] matching = {}; foreach (SensorReading reading in monitor.readings()) { if (reading.kind == kind) { - any = true; - break; + matching += reading; } } - if (!any) { + if (matching.length == 0) { return; } - // Ungrouped flattens the list by hiding the per-kind heading; - // the rows themselves (and their MAX_ROWS_PER_GROUP cap / overflow - // averaging below) are unchanged, same field the Clocks section - // toggles -- see sensors_grouped's declaration. + if (sensors_grouped) { - add_heading(title); + // Collapse the whole family into ONE row: the average + // temperature across every reading of this kind, labelled by + // the kind itself rather than any individual sensor. No + // heading -- the row's own label ("CPU", "GPU", ...) already + // says what it is. Deliberately uncoloured and bar-less, same + // reasoning as the overflow rows in the ungrouped branch: + // severity is classified per-sensor against that sensor's own + // limit, and averaging across sensors -- let alone an entire + // family of them -- has no single threshold to colour or + // scale a bar against. + int64 sum = 0; + foreach (SensorReading reading in matching) { + sum += reading.millidegrees; + } + add_row(title, format_celsius((int) (sum / matching.length))); + return; } + + add_heading(title); // Cap the rows. Sensor count varies enormously by platform: an ARM // dev board reports 5, a Qualcomm SC8280XP reports 55. Listing all // of them turns the popover into a wall of near-identical numbers, @@ -780,10 +793,7 @@ namespace Singularity { int shown = 0; int hidden = 0; int64 hidden_millidegrees_sum = 0; - foreach (SensorReading reading in monitor.readings()) { - if (reading.kind != kind) { - continue; - } + foreach (SensorReading reading in matching) { if (shown < MAX_ROWS_PER_GROUP) { add_row(reading.label, format_celsius(reading.millidegrees), reading.severity, reading.heat_fraction); @@ -805,6 +815,140 @@ namespace Singularity { } } + /** + * CPU temperature AND clock, in one section instead of two. They used + * to be separate ("CPU" from add_group(), "Clocks" lower down in + * rebuild_details()) which put two headings on the same physical + * silicon with nothing connecting them. + * + * They stay two DIFFERENT KINDS OF ROW within that one section, + * though, rather than one merged "50C / 2.6GHz" row per cluster: + * CIX Sky1 names four thermal zones (CPU_B0/B1, CPU_M0/M1) that do + * NOT partition onto the same five cpufreq clusters cluster_id + * reports (verified on O6N: cluster_id 1/2/3/4/5 map exactly to + * cpufreq policy0/2/6/8/10, one cluster per policy -- but there is + * no sysfs link from a named thermal zone to the core numbers it + * actually measures). Attaching a temperature to a specific clock + * cluster would be a guess dressed up as a measurement. So: one + * aggregate temperature for the whole CPU, and clock broken out by + * cluster underneath it. + */ + private void add_cpu_section() { + SensorReading[] cpu_temps = {}; + foreach (SensorReading reading in monitor.readings()) { + if (reading.kind == SensorKind.CPU) { + cpu_temps += reading; + } + } + // Honour sensors-show-frequency here too. It previously gated + // only the compact summary, so turning frequency "off" still + // rendered the entire Clocks section the moment the popover was + // opened -- the preference silently did half of what it says. + ClockReading[] clocks = show_frequency ? monitor.clocks() : new ClockReading[0]; + if (cpu_temps.length == 0 && clocks.length == 0) { + return; + } + + if (sensors_grouped) { + if (cpu_temps.length > 0) { + int64 sum = 0; + foreach (SensorReading reading in cpu_temps) { + sum += reading.millidegrees; + } + add_row(_("CPU"), format_celsius((int) (sum / cpu_temps.length))); + } + // Group by max_khz -- the actual performance-tier signal. + // clocks() is one entry per cpufreq POLICY, and a policy is + // a clock domain: cores sharing one on a heterogeneous SoC + // (Sky1's five policies) are exactly the cores in the same + // tier, so an equal max_khz reliably identifies "same tier" + // without needing core-type names the backend doesn't have. + // On a homogeneous desktop CPU where every core reports the + // same max, this collapses a hundred identical rows into + // one. On Sky1 specifically every policy happens to have a + // DIFFERENT ceiling, so grouped and ungrouped render almost + // identically there -- the toggle exists so that is + // verifiable rather than assumed, and so it still collapses + // rows on hardware where policies genuinely share a ceiling. + // + // Plain parallel arrays + linear scan rather than a Gee map: + // tier count is always small (Sky1 has 5 policies at most), + // so the O(n*tiers) scan costs nothing, and it avoids any + // uncertainty about Gee's generic-boxing behaviour for a + // primitive int key. + int[] tier_max = {}; + int64[] tier_khz_sum = {}; + int[] tier_count = {}; + foreach (ClockReading c in clocks) { + int idx = -1; + for (int i = 0; i < tier_max.length; i++) { + if (tier_max[i] == c.max_khz) { idx = i; break; } + } + if (idx < 0) { + tier_max += c.max_khz; + tier_khz_sum += (int64) c.khz; + tier_count += 1; + } else { + tier_khz_sum[idx] += c.khz; + tier_count[idx] += 1; + } + } + // Fastest tier first: the one most people check first, and + // matches how the sensor groups above already read + // hottest-first. + for (int i = 0; i < tier_max.length; i++) { + for (int j = i + 1; j < tier_max.length; j++) { + if (tier_max[j] > tier_max[i]) { + int tmp_max = tier_max[i]; tier_max[i] = tier_max[j]; tier_max[j] = tmp_max; + int64 tmp_sum = tier_khz_sum[i]; tier_khz_sum[i] = tier_khz_sum[j]; tier_khz_sum[j] = tmp_sum; + int tmp_cnt = tier_count[i]; tier_count[i] = tier_count[j]; tier_count[j] = tmp_cnt; + } + } + } + for (int i = 0; i < tier_max.length; i++) { + int avg_khz = (int) (tier_khz_sum[i] / tier_count[i]); + string label = tier_count[i] > 1 + ? _("%d cores").printf(tier_count[i]) + : _("1 core"); + string value = tier_max[i] > 0 + ? "%s / %s".printf(format_clock(avg_khz), format_clock(tier_max[i])) + : format_clock(avg_khz); + add_row(label, value); + } + return; + } + + add_heading(_("CPU")); + int shown = 0; + int hidden = 0; + int64 hidden_millidegrees_sum = 0; + foreach (SensorReading reading in cpu_temps) { + if (shown < MAX_ROWS_PER_GROUP) { + add_row(reading.label, format_celsius(reading.millidegrees), + reading.severity, reading.heat_fraction); + shown++; + } else { + hidden++; + hidden_millidegrees_sum += reading.millidegrees; + } + } + if (hidden > 0) { + add_row(_("%d more").printf(hidden), + format_celsius((int) (hidden_millidegrees_sum / hidden))); + } + // Raw, one row per cpufreq policy, in whatever order clocks() + // returned them -- no grouping, no averaging. The label is the + // policy's own sysfs directory name (e.g. "policy0"), the same + // identifier a person would see if they went and looked at + // /sys/devices/system/cpu/cpufreq/ themselves. + foreach (ClockReading c in clocks) { + string value = c.max_khz > 0 + ? "%s / %s".printf(format_clock(c.khz), format_clock(c.max_khz)) + : format_clock(c.khz); + add_row(c.label, value); + } + } + /** Built only while the popover is open. */ private void rebuild_details() { Gtk.Widget? child = detail_box.get_first_child(); @@ -826,7 +970,7 @@ namespace Singularity { // kinds were classified and then silently dropped -- on Sky1 that // hid eleven of nineteen readings, including the NVMe that was the // only one worth looking at. - add_group(SensorKind.CPU, _("CPU")); + add_cpu_section(); add_group(SensorKind.GPU, _("GPU")); add_group(SensorKind.NPU, _("NPU")); add_group(SensorKind.VPU, _("VPU")); @@ -837,100 +981,6 @@ namespace Singularity { add_group(SensorKind.SYSTEM, _("System")); add_utilization_details(); - - // Clocks are NOT colour-coded. A core at its maximum is doing its - // job, not overheating, and painting it red would train the user to - // ignore the colour that does mean something. They are shown - // against their own ceiling instead, because that ceiling is not - // one number per machine: CIX Sky1 has five cpufreq policies with - // five different maxima, so "1.4 GHz" is nearly flat out on one - // cluster and near idle on another. - // Honour sensors-show-frequency here too. It previously gated - // only the compact summary, so turning frequency "off" still - // rendered the entire Clocks section the moment the popover was - // opened -- the preference silently did half of what it says. - ClockReading[] clocks = show_frequency ? monitor.clocks() : new ClockReading[0]; - if (clocks.length > 0) { - if (sensors_grouped) { - add_heading(_("Clocks")); - } - - if (sensors_grouped) { - // Group by max_khz -- the actual performance-tier - // signal. clocks() is one entry per cpufreq POLICY, and - // a policy is a clock domain: cores sharing one on a - // heterogeneous SoC (Sky1's five policies) are exactly - // the cores in the same tier, so an equal max_khz - // reliably identifies "same tier" without needing - // core-type names the backend doesn't have. On a - // homogeneous desktop CPU where every core reports the - // same max, this collapses a hundred identical rows - // into one. On Sky1 specifically every policy happens - // to have a DIFFERENT ceiling, so grouped and ungrouped - // render almost identically there -- the toggle below - // exists so that is verifiable rather than assumed, and - // so it still collapses rows on hardware where policies - // genuinely do share a ceiling. - // - // Plain parallel arrays + linear scan rather than a Gee - // map: tier count is always small (Sky1 has 5 policies - // at most), so the O(n*tiers) scan costs nothing, and it - // avoids any uncertainty about Gee's generic-boxing - // behaviour for a primitive int key. - int[] tier_max = {}; - int64[] tier_khz_sum = {}; - int[] tier_count = {}; - foreach (ClockReading c in clocks) { - int idx = -1; - for (int i = 0; i < tier_max.length; i++) { - if (tier_max[i] == c.max_khz) { idx = i; break; } - } - if (idx < 0) { - tier_max += c.max_khz; - tier_khz_sum += (int64) c.khz; - tier_count += 1; - } else { - tier_khz_sum[idx] += c.khz; - tier_count[idx] += 1; - } - } - // Fastest tier first: the one most people check first, - // and matches how the sensor groups above already read - // hottest-first. - for (int i = 0; i < tier_max.length; i++) { - for (int j = i + 1; j < tier_max.length; j++) { - if (tier_max[j] > tier_max[i]) { - int tmp_max = tier_max[i]; tier_max[i] = tier_max[j]; tier_max[j] = tmp_max; - int64 tmp_sum = tier_khz_sum[i]; tier_khz_sum[i] = tier_khz_sum[j]; tier_khz_sum[j] = tmp_sum; - int tmp_cnt = tier_count[i]; tier_count[i] = tier_count[j]; tier_count[j] = tmp_cnt; - } - } - } - for (int i = 0; i < tier_max.length; i++) { - int avg_khz = (int) (tier_khz_sum[i] / tier_count[i]); - string label = tier_count[i] > 1 - ? _("%d cores").printf(tier_count[i]) - : _("1 core"); - string value = tier_max[i] > 0 - ? "%s / %s".printf(format_clock(avg_khz), format_clock(tier_max[i])) - : format_clock(avg_khz); - add_row(label, value); - } - } else { - // Raw, one row per cpufreq policy, in whatever order - // clocks() returned them -- no grouping, no averaging. - // The label is the policy's own sysfs directory name - // (e.g. "policy0"), the same identifier a person would - // see if they went and looked at - // /sys/devices/system/cpu/cpufreq/ themselves. - foreach (ClockReading c in clocks) { - string value = c.max_khz > 0 - ? "%s / %s".printf(format_clock(c.khz), format_clock(c.max_khz)) - : format_clock(c.khz); - add_row(c.label, value); - } - } - } } }