From 25b590907c3bcccb418b7f9e8a77cb22c5e4f39f Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Tue, 11 Aug 2026 04:29:03 +0000 Subject: [PATCH 01/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] =?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/13] 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/13] 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/13] 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/13] 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/13] 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; }