diff --git a/src/components/panel/panel.vala b/src/components/panel/panel.vala
index c3deac1..0992d7f 100644
--- a/src/components/panel/panel.vala
+++ b/src/components/panel/panel.vala
@@ -4,6 +4,986 @@ using Gee;
namespace Singularity {
+ /**
+ * SensorsIndicator — one compact chip in the panel, detail in a popover.
+ *
+ * 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.
+ *
+ * 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 class SensorsIndicator : Gtk.Box {
+ // 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 SensorMonitor monitor;
+ private bool show_frequency = true;
+ private bool show_utilization = 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
+ // (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");
+ // 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");
+ button.tooltip_text = _("Temperatures and CPU clock");
+ button.child = summary_label;
+ append(button);
+
+ 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;
+ Popover popover = new Popover();
+ // 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.
+ //
+ // 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;
+ 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
+ // 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 (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");
+ }
+ 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,
+ // 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")) {
+ 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")) {
+ string cpu_zone = settings.get_string("sensors-cpu-zone");
+ if (cpu_zone != "") monitor.cpu_hint = cpu_zone;
+ }
+
+ 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
+ // 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();
+ // 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
+ // (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);
+ if (show_utilization) util.start();
+ });
+ unmap.connect(() => {
+ if (hidden_for_unavailable) return;
+ monitor.stop();
+ util.stop();
+ });
+ 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();
+ }
+
+ private static string format_celsius(int millidegrees) {
+ return "%d°".printf((millidegrees + 500) / 1000);
+ }
+
+ private static string format_clock(int khz) {
+ return khz >= 1000000
+ ? "%.1f GHz".printf(khz / 1000000.0)
+ : "%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;
+ }
+
+ /**
+ * 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) {
+ // 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 (!style.lookup_color(color_name, out rgba)) {
+ if (!style.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;
+ 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() {
+ // 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
+ // 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
+ // 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;
+
+ // 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;
+
+ // 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
+ && reading.millidegrees == primary) {
+ primary_severity = reading.severity;
+ break;
+ }
+ }
+ // 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");
+
+ StringBuilder markup = new StringBuilder();
+ if (primary >= 0) {
+ markup.append(markup_segment(theme_color_hex(severity_color_name(primary_severity)),
+ format_celsius(primary)));
+ }
+ if (show_frequency && monitor.cpu_khz > 0) {
+ 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.
+ //
+ // 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 (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 (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 = markup.str;
+
+ Popover? popover = button.popover;
+ if (popover != null && popover.visible) {
+ rebuild_details();
+ }
+ }
+
+ private void add_heading(string title) {
+ Label heading = new Label(title);
+ heading.add_css_class("heading");
+ heading.halign = Align.START;
+ heading.margin_top = 4;
+ detail_box.append(heading);
+ }
+
+ /**
+ * 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_sensors_toggle() {
+ Box row = new Box(Orientation.HORIZONTAL, 6);
+ row.margin_top = 4;
+
+ Label heading = new Label(_("Sensors"));
+ 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 = sensors_grouped ? _("Grouped") : _("Ungrouped");
+ toggle.clicked.connect(() => {
+ sensors_grouped = !sensors_grouped;
+ SettingsSchema? schema = settings.settings_schema;
+ if (schema != null && schema.has_key("sensors-grouped")) {
+ settings.set_boolean("sensors-grouped", sensors_grouped);
+ }
+ rebuild_details();
+ });
+ row.append(toggle);
+
+ detail_box.append(row);
+ }
+
+ /**
+ * 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.
+ */
+ /**
+ * 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";
+ case Severity.HOT: return "warning";
+ case Severity.WARM: return null;
+ default: return "dim-label";
+ }
+ }
+
+ /**
+ * 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,
+ 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(value_label);
+ 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;
+ double hidden_sum = 0.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++;
+ 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),
+ "%d%%".printf(percent_of(hidden_sum / 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;
+ // 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;
+ }
+ 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_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;
+ }
+ 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_disks++;
+ hidden_disk_sum += disk.fraction;
+ }
+ }
+ if (hidden_disks > 0) {
+ add_row(_("%d more").printf(hidden_disks),
+ "%d%%".printf(percent_of(hidden_disk_sum / hidden_disks)));
+ }
+ }
+ }
+
+ private void add_group(SensorKind kind, string title) {
+ SensorReading[] matching = {};
+ foreach (SensorReading reading in monitor.readings()) {
+ if (reading.kind == kind) {
+ matching += reading;
+ }
+ }
+ if (matching.length == 0) {
+ return;
+ }
+
+ if (sensors_grouped) {
+ // 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,
+ // 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 matching) {
+ 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;
+ }
+ }
+ // 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),
+ format_celsius((int) (hidden_millidegrees_sum / hidden)));
+ }
+ }
+
+ /**
+ * 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();
+ while (child != null) {
+ detail_box.remove(child);
+ 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.
+ //
+ // 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_cpu_section();
+ 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"));
+
+ add_utilization_details();
+ }
+ }
+
private class TilingPositionIndicator : Gtk.Fixed {
private const int TRACK_WIDTH = 58;
private const int TRACK_HEIGHT = 18;
@@ -610,6 +1590,18 @@ 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 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();
_settings.changed["panel-layout-left"].connect(() => {
if (!saving_bar_layout) reload_bar_layout();
@@ -628,8 +1620,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,13 +1958,13 @@ 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,
{ "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")
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;
diff --git a/src/core/system_monitor.vala b/src/core/system_monitor.vala
index ef9c052..1373829 100644
--- a/src/core/system_monitor.vala
+++ b/src/core/system_monitor.vala
@@ -16,6 +16,133 @@ 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; } }
+ /**
+ * 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.
+ */
+ /**
+ * 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) {
+ _sensors = new SensorMonitor();
+ // 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) {
+ // "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 (!File.new_for_path(path).load_contents(null, out raw, null)) {
+ continue;
+ }
+ } catch (Error e) {
+ continue;
+ }
+ 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;
+ }
+ }
+ return false;
+ }
public CallMonitor call_monitor { get { if (_call_monitor == null) _call_monitor = new CallMonitor(audio); return _call_monitor; } }
private PowerManager? _power;
@@ -31,6 +158,8 @@ namespace Singularity {
private BluetoothManager? _bluetooth;
private PowerProfilesManager? _power_profiles;
private ResourceMonitor? _resources;
+ private SensorMonitor? _sensors = null;
+ private UtilizationMonitor? _utilization = null;
private CallMonitor? _call_monitor;
public static SystemMonitor get_default() {