diff --git a/src/components/panel/panel.vala b/src/components/panel/panel.vala index c3deac1..fa800a3 100644 --- a/src/components/panel/panel.vala +++ b/src/components/panel/panel.vala @@ -4,6 +4,484 @@ 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; + // 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); + valign = Align.CENTER; + add_css_class("sensors-indicator"); + + 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); + + 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; + + // 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"); + } + // 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); + // 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 + // (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(() => { + if (hidden_for_unavailable) return; + monitor.stop(); + }); + if (get_mapped()) monitor.start(interval); + } + + public override void dispose() { + monitor.updated.disconnect(on_updated); + monitor.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); + } + + 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 + // 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 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)); + } + if (show_frequency && monitor.cpu_khz > 0) { + if (text.len > 0) { + text.append(" · "); + } + text.append(format_clock(monitor.cpu_khz)); + } + summary_label.label = text.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); + } + + /** + * 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"; + } + } + + /** + * 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); + } + + private void add_group(SensorKind kind, string title) { + bool any = false; + foreach (SensorReading reading in monitor.readings()) { + if (reading.kind == kind) { + any = true; + break; + } + } + if (!any) { + 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; + 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), + reading.severity, reading.heat_fraction); + shown++; + } else { + hidden++; + } + } + if (hidden > 0) { + add_row(_("%d more").printf(hidden), ""); + } + } + + /** 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(); + } + + // 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 + // ignore the colour that does mean something. They are shown + // against their own ceiling instead, because that ceiling is not + // one number per machine: CIX Sky1 has five cpufreq policies with + // five different maxima, so "1.4 GHz" is nearly flat out on one + // cluster and near idle on another. + // Honour sensors-show-frequency here too. It previously gated + // only the compact summary, so turning frequency "off" still + // rendered the entire Clocks section the moment the popover was + // opened -- the preference silently did half of what it says. + ClockReading[] clocks = show_frequency ? monitor.clocks() : new ClockReading[0]; + if (clocks.length > 0) { + add_heading(_("Clocks")); + // Same cap-and-count convention as add_group() above: a + // per-CPU cpufreq policy (one entry per core on some x86 + // layouts) can run past a hundred, and the popover has no + // scroll container, so an uncapped list grows off-screen. + int shown = int.min(clocks.length, MAX_ROWS_PER_GROUP); + for (int i = 0; i < shown; i++) { + string value = clocks[i].max_khz > 0 + ? "%s / %s".printf(format_clock(clocks[i].khz), + format_clock(clocks[i].max_khz)) + : format_clock(clocks[i].khz); + add_row(_("Core group %d").printf(i + 1), value); + } + if (clocks.length > shown) { + add_row(_("%d more").printf(clocks.length - shown), ""); + } + } + } + } + private class TilingPositionIndicator : Gtk.Fixed { private const int TRACK_WIDTH = 58; private const int TRACK_HEIGHT = 18; @@ -610,6 +1088,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 +1118,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 +1456,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..6f50d37 100644 --- a/src/core/system_monitor.vala +++ b/src/core/system_monitor.vala @@ -16,6 +16,116 @@ 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. + */ + 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 +141,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() {