Add Framework laptop controls and monitoring with hardware-aware discovery
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
project(framework-laptop-tools VERSION 0.1.0 LANGUAGES CXX)
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
find_package(ECM 6.0 REQUIRED NO_MODULE)
|
||||
set(CMAKE_MODULE_PATH ${ECM_MODULE_PATH})
|
||||
include(KDEInstallDirs)
|
||||
include(KDECMakeSettings)
|
||||
include(CTest)
|
||||
find_package(Qt6 6.8 REQUIRED COMPONENTS Core Widgets DBus Test)
|
||||
find_package(KF6Auth REQUIRED)
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(Systemd REQUIRED IMPORTED_TARGET libsystemd)
|
||||
add_library(framework-hardware STATIC src/hardware.cpp src/fan.cpp src/cpu.cpp)
|
||||
target_include_directories(framework-hardware PUBLIC src)
|
||||
target_link_libraries(framework-hardware PUBLIC Qt6::Core)
|
||||
set(gui_sources src/window.cpp src/chart.cpp src/legend.cpp src/tray.cpp src/traypage.cpp src/colorbutton.cpp src/cpupage.cpp src/valuecontrol.cpp)
|
||||
add_executable(framework-laptop-tools src/main.cpp ${gui_sources})
|
||||
target_link_libraries(framework-laptop-tools PRIVATE framework-hardware Qt6::Widgets Qt6::DBus KF6::AuthCore)
|
||||
add_executable(framework-laptop-tools-helper src/helper.cpp)
|
||||
target_link_libraries(framework-laptop-tools-helper PRIVATE framework-hardware KF6::AuthCore Qt6::DBus)
|
||||
add_executable(framework-laptop-tools-fan src/fanservice.cpp)
|
||||
target_link_libraries(framework-laptop-tools-fan PRIVATE framework-hardware PkgConfig::Systemd)
|
||||
add_executable(framework-laptop-tools-cpu src/cpuservice.cpp)
|
||||
target_link_libraries(framework-laptop-tools-cpu PRIVATE framework-hardware Qt6::DBus PkgConfig::Systemd)
|
||||
install(TARGETS framework-laptop-tools DESTINATION ${KDE_INSTALL_BINDIR})
|
||||
install(TARGETS framework-laptop-tools-helper DESTINATION ${KAUTH_HELPER_INSTALL_DIR})
|
||||
install(TARGETS framework-laptop-tools-fan DESTINATION libexec)
|
||||
install(TARGETS framework-laptop-tools-cpu DESTINATION libexec)
|
||||
kauth_install_helper_files(framework-laptop-tools-helper se.ajpanton.frameworktools root)
|
||||
kauth_install_actions(se.ajpanton.frameworktools data/se.ajpanton.frameworktools.actions)
|
||||
install(FILES data/se.ajpanton.framework-laptop-tools.desktop DESTINATION ${KDE_INSTALL_APPDIR})
|
||||
install(FILES data/framework-laptop-tools.json DESTINATION ${KDE_INSTALL_DATADIR}/fedora-tools/settings)
|
||||
install(FILES data/framework-laptop-tools-fan.service data/framework-laptop-tools-fan-resume.service DESTINATION /usr/lib/systemd/system)
|
||||
install(FILES data/framework-laptop-tools-cpu.service DESTINATION /usr/lib/systemd/system)
|
||||
if(BUILD_TESTING)
|
||||
add_executable(test-hardware tests/test-hardware.cpp)
|
||||
target_link_libraries(test-hardware PRIVATE framework-hardware Qt6::Test)
|
||||
add_test(NAME hardware COMMAND test-hardware)
|
||||
add_executable(test-window tests/test-window.cpp ${gui_sources})
|
||||
target_link_libraries(test-window PRIVATE framework-hardware Qt6::Widgets Qt6::DBus Qt6::Test KF6::AuthCore)
|
||||
add_test(NAME window COMMAND test-window)
|
||||
set_tests_properties(window PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
|
||||
endif()
|
||||
@@ -0,0 +1,184 @@
|
||||
# Framework Laptop Tools
|
||||
|
||||
Experimental native desktop/tray controls for **Framework Laptop 13 Pro
|
||||
(Intel Core Ultra Series 3)**. Other Framework models are not yet validated.
|
||||
Install `framework-laptop-tools`, then open **Framework Laptop Tools** from the
|
||||
application menu or Fedora Tools settings. Installation changes no hardware
|
||||
settings. Hardware changes require administrator authorisation.
|
||||
|
||||
The window has Monitor, Lighting, Cooling, Battery, CPU, Tray icon and Preferences
|
||||
tabs.
|
||||
Bounded hardware settings use sliders with numeric fields for precise entry.
|
||||
All settings tabs share a **Save and Apply / Undo changes** bar. Editing stages
|
||||
changes without writing hardware, tray settings, sampling intervals or autostart.
|
||||
The bar stays visible across tabs. Save applies all pending settings with one
|
||||
administrator authorisation for hardware changes; Undo discards all unsaved edits.
|
||||
Successful saves become the new Undo baseline. If a hardware operation fails,
|
||||
earlier operations may already have applied; remaining edits stay pending.
|
||||
CPU slider bounds are read from Linux's hardware-frequency limits.
|
||||
|
||||
The Monitor page has CPU/GPU frequency, fan RPM, temperature and battery graphs.
|
||||
The battery heading shows full capacity, health and cycle count when available.
|
||||
Capacity calculated from charge capacity and nominal voltage is approximate.
|
||||
Temperature labels distinguish CPU-area/board sensors from die readings;
|
||||
raw sysfs paths remain available in tooltips. CPU frequency is Linux's reported
|
||||
average across CPU policies, not an instantaneous measurement of every core.
|
||||
GPU GT domains are shown separately rather than assuming they are identical.
|
||||
Click a coloured legend to toggle its series. Hidden series remain in the legend,
|
||||
greyed out and crossed out. Main temperature sensors are shown first; additional
|
||||
sensors are in a collapsed section that remembers their selections, not its open
|
||||
state. NVMe composite is the drive's overall reported temperature; numbered NVMe
|
||||
sensors are device-specific. Memory (SPD) measures the memory module, while EC
|
||||
area sensors are separate from processor/module readings.
|
||||
Each graph starts with blue. Further colours maximise their minimum OKLab distance
|
||||
from enabled colours within a readable candidate palette. Enabling a line assigns
|
||||
its colour; existing enabled lines keep theirs. Disabled colours are released.
|
||||
|
||||
Axes use round ticks and relative ages, with units below the vertical axes.
|
||||
Hover draws a guide at the same timestamp on every graph, even when their
|
||||
time spans differ. The tooltip still describes only the graph under the pointer.
|
||||
Hover for timestamps and the nearest
|
||||
available readings; no readings are invented across gaps or sleep. Battery charge
|
||||
uses the left percentage axis and rate the right watts axis (positive charging,
|
||||
negative discharging). Blue-grey bands mark logind-observed sleep; faint red bands
|
||||
mark observed charger connection. Plug changes during sleep are unknown, so AC
|
||||
bands are not extended through sleep. There is no history from before app startup.
|
||||
History is bounded to 600 samples per series and kept only while the app runs.
|
||||
|
||||
Sampling choices are 0.5/1/2/4 seconds for frequencies and temperatures, and
|
||||
15/30/60/120 seconds for the battery graph. Tray autostart is optional.
|
||||
Closing the window leaves monitoring in the tray; Quit exits the application.
|
||||
The Tray icon tab selects a normal icon, a history graph, or a number. Sources
|
||||
include CPU usage, CPU/GPU frequency, temperatures, charge level and battery rate.
|
||||
Sensor graphs share Monitor's history, including gaps, regardless of which
|
||||
sensor is selected for the tray. CPU usage also retains 600 samples. Switching
|
||||
readings or resuming from sleep does not clear history. Frequency numbers use
|
||||
GHz; the hover tooltip includes the full reading and unit. The tray has optional
|
||||
borders, a transparent or coloured background, line and fill colours, and optional
|
||||
area fill (including adjustable opacity). Frequency ceilings and temperature
|
||||
ranges are saved per sensor; battery rate also has adjustable bounds (initially
|
||||
−75–75 W), while percentages use 0–100.
|
||||
Outside readings can follow the inner edge, optionally in a different colour,
|
||||
or be hidden. The line remains inside the border when a border is enabled.
|
||||
Colour buttons show the opaque RGB swatch and label opacity separately, so a
|
||||
translucent fill is not mistaken for a darker RGB colour.
|
||||
Plasma applies its own hover highlight to tray icons; the app does not patch
|
||||
the system tray to suppress that effect.
|
||||
|
||||
## Controls and limits
|
||||
|
||||
- Keyboard brightness: 0–100%. **Use Fn+Space to leave or enter Auto mode.**
|
||||
The inspected 13 Pro firmware does not expose a host command to change that
|
||||
mode; its ambient-light logic overrides manual percentage changes in Auto.
|
||||
- Power-button brightness: 1–100%, or firmware Auto. This is the illuminated
|
||||
power button surrounding the fingerprint reader, not its authentication.
|
||||
The Automatic brightness checkbox disables the slider; both mode and brightness
|
||||
are staged until Save and Apply. Unchecking it allows a fixed brightness to be selected.
|
||||
- Fan: firmware Auto, manual duty, or a four-point curve. Manual values
|
||||
range from 0–100%, including fan off. Manual speeds below 30% show a warning.
|
||||
Saving manual or curve speeds below 30% also requires explicit confirmation.
|
||||
Four editable temperatures must increase between 20 and 85 °C, reaching 100%
|
||||
duty at the final point. Speeds between points are interpolated. The hottest EC sensor
|
||||
drives the curve. Speed increases immediately and decreases gradually.
|
||||
The privileged worker returns to Auto on suspend, reboot, a sensor fault,
|
||||
high temperatures, watchdog timeout or service exit. It does not change
|
||||
thermal warning/shutdown thresholds. Overrides remain active with the GUI closed.
|
||||
Saving an override writes `/etc/framework-laptop-tools/fan.json` and enables
|
||||
the worker at boot and after sleep. Sleep still stops the worker and restores
|
||||
Auto first. Failure restores Auto without a restart loop;
|
||||
saved settings are retried at the next boot/resume or explicit Save and Apply.
|
||||
Legacy temporary overrides are still recognised until stopped, but newly saved
|
||||
overrides always persist; installation itself does not enable them.
|
||||
Selecting firmware Auto removes saved settings and disables automatic startup.
|
||||
Do not combine with another fan controller.
|
||||
- Battery charge limit: 50–100%, stored by the firmware. Charge-power settings
|
||||
convert watts to a current limit using present battery voltage; actual watts
|
||||
vary with voltage and system/charger limits. Zero restores firmware defaults.
|
||||
This is **battery charging power**, not wall power or total laptop power.
|
||||
Charge current has no read-back command in the interface used here.
|
||||
The C-rate used by some other utilities expresses current relative to battery
|
||||
capacity: 1 C means 4.64 A for a 4.64 Ah battery, not a fixed number of watts.
|
||||
- CPU: minimum/maximum frequency, governor and energy preference, with optional
|
||||
separate battery/AC profiles. Frequency bounds are clamped per policy: a
|
||||
requested 4.5 GHz maximum does not restrict a P-core to a slower core's
|
||||
3.3 GHz ceiling. These are bounds, not guaranteed clock speeds.
|
||||
On Intel hybrid systems, kernel `cpu_core`/`cpu_atom` PMU membership identifies
|
||||
P-core and E-core policy groups, each with independent overrides. E-core limits
|
||||
clamp to each E-core's own ceiling, including lower-power E-cores. If the kernel
|
||||
cannot identify the groups unambiguously, a single shared range is offered.
|
||||
|
||||
Lighting and battery readings refresh on entry and every two seconds while their
|
||||
tab is visible. Unsaved edits are protected. Undo restores the unedited snapshot;
|
||||
normal live refresh then resumes. Charge-current limits cannot be read back, so
|
||||
their initial draft is firmware default, not a claim about current hardware state.
|
||||
Graph legend toggles remain immediate viewing controls, separate from staged
|
||||
settings tabs.
|
||||
|
||||
Battery time remaining/full uses UPower when available. Time to a custom charge
|
||||
limit is approximate, based on present current; charging taper makes it less
|
||||
accurate near full. No estimate is displayed when the needed readings are absent.
|
||||
|
||||
## Saved CPU profiles
|
||||
|
||||
CPU editing is staged. Splitting copies the shared values into both columns;
|
||||
battery is on the left and AC on the right. Joining uses the battery column.
|
||||
The hidden AC draft remains recoverable by splitting again until **Save and
|
||||
apply** commits the joined profile. Shared configurations store only one copy.
|
||||
**Undo changes** restores the last saved configuration. Background
|
||||
firmware refreshes do not overwrite a dirty CPU draft.
|
||||
|
||||
Frequency bounds, governor and energy preference are independent. Frequency
|
||||
overriding is unchecked by default, and both dropdowns default to **auto**.
|
||||
Auto means this tool never writes that attribute; it is not a preset or reset.
|
||||
Saving all-Auto profiles with frequency overriding off stops/disables the CPU
|
||||
service. Existing CPU limits are left unchanged, including when giving up a
|
||||
previous override, switching to an Auto power-source profile, or uninstalling.
|
||||
There is no TuneD reload or blanket restoration that could disturb another
|
||||
controller. Avoid assigning the same setting to multiple controllers.
|
||||
|
||||
The top of the CPU page always shows live Linux values, independently of the
|
||||
draft below. The frequency sliders are disabled when their override is unchecked;
|
||||
governor and EPP remain independently selectable. Tooltip explanations replace
|
||||
the longer instructions formerly at the bottom of the page.
|
||||
|
||||
Explicit overrides apply at boot, resume, power-source changes and successful
|
||||
TuneD profile changes after two seconds for the transition to settle. No TuneD
|
||||
dependency is required. The service works with the GUI closed; configuration is
|
||||
root-owned at `/etc/framework-laptop-tools/cpu.json`. Earlier all-or-nothing configurations
|
||||
are converted to independent overrides while preserving whether they were enabled.
|
||||
|
||||
With active Intel HWP, the Performance governor forces performance EPP and rejects
|
||||
other EPP values. For an explicitly selected Performance governor, this tool uses
|
||||
Powersave when the selected EPP (or the live EPP when Auto) is incompatible. The
|
||||
dropdown retains the user's choice and displays a warning. With EPP on Auto, the
|
||||
service observes EPP changes and reevaluates that explicit governor choice.
|
||||
|
||||
If governor is Auto and the live Performance governor blocks an explicitly chosen
|
||||
non-performance EPP, applying fails with a clear message: choose Powersave or leave
|
||||
EPP on Auto. It does not silently take over the Auto governor. Explicit governor
|
||||
changes can themselves affect EPP inside the kernel; Auto does not undo those
|
||||
kernel side effects. Linux does not provide independent control of every combination.
|
||||
|
||||
Thermal, power and boost limits still apply. Powersave on active Intel HWP allows
|
||||
dynamic clocks and boost; it does not mean locking the CPU at minimum frequency.
|
||||
|
||||
## Interfaces
|
||||
|
||||
No firmware patch, raw port I/O, downloaded executable, or third-party service
|
||||
is used. Monitoring reads Linux sysfs and UPower. A restricted KAuth helper
|
||||
uses sysfs and `/dev/cros_ec` for documented EC commands. Model checks are
|
||||
repeated in the privileged helper, not just in the GUI.
|
||||
|
||||
References:
|
||||
|
||||
- [Framework's hardware library and CLI](https://github.com/FrameworkComputer/framework-system)
|
||||
- [13 Pro keyboard firmware](https://github.com/FrameworkComputer/EmbeddedController/blob/fwk-sakura-20260429/zephyr/program/framework/src/keyboard_customization_13.c)
|
||||
- [Framework LED commands](https://github.com/FrameworkComputer/EmbeddedController/blob/fwk-sakura-20260429/zephyr/program/framework/src/led.c)
|
||||
- [Linux EC hardware monitor](https://www.kernel.org/doc/html/latest/hwmon/cros_ec_hwmon.html)
|
||||
- [Linux CPU frequency controls](https://www.kernel.org/doc/html/latest/admin-guide/pm/cpufreq.html)
|
||||
- [Intel governor and EPP behaviour](https://www.kernel.org/doc/html/latest/admin-guide/pm/intel_pstate.html)
|
||||
- [TuneD profile documentation](https://tuned-project.org/docs/manual.html)
|
||||
- [Linux battery units](https://www.kernel.org/doc/html/latest/power/power_supply_class.html)
|
||||
- [Framework Control](https://github.com/ozturkkl/framework-control) and
|
||||
[framework-tool-tui](https://github.com/grouzen/framework-tool-tui) provide
|
||||
useful interface references; their code is not bundled here.
|
||||
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=Framework Laptop Tools saved CPU profiles
|
||||
After=tuned.service
|
||||
ConditionPathExists=/etc/framework-laptop-tools/cpu.json
|
||||
|
||||
[Service]
|
||||
Type=notify
|
||||
ExecStart=/usr/libexec/framework-laptop-tools-cpu
|
||||
WatchdogSec=15
|
||||
TimeoutStartSec=20
|
||||
NoNewPrivileges=yes
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
ProtectSystem=full
|
||||
RestrictAddressFamilies=AF_UNIX
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,13 @@
|
||||
[Unit]
|
||||
Description=Restore saved Framework fan settings after sleep
|
||||
Before=sleep.target
|
||||
StopWhenUnneeded=yes
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
ExecStart=/usr/bin/true
|
||||
ExecStop=/usr/libexec/framework-laptop-tools-fan --resume
|
||||
|
||||
[Install]
|
||||
WantedBy=sleep.target
|
||||
@@ -0,0 +1,20 @@
|
||||
[Unit]
|
||||
Description=Framework Laptop Tools fan override
|
||||
After=systemd-modules-load.service
|
||||
Conflicts=sleep.target
|
||||
Before=sleep.target
|
||||
|
||||
[Service]
|
||||
Type=notify
|
||||
ExecStart=/usr/libexec/framework-laptop-tools-fan
|
||||
ExecStopPost=/usr/libexec/framework-laptop-tools-fan --restore
|
||||
WatchdogSec=10
|
||||
TimeoutStopSec=5
|
||||
NoNewPrivileges=yes
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
ProtectSystem=full
|
||||
RestrictAddressFamilies=AF_UNIX
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1 @@
|
||||
{"command": ["/usr/bin/framework-laptop-tools"]}
|
||||
@@ -0,0 +1,7 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Framework Laptop Tools
|
||||
Comment=Monitor and configure supported Framework laptops
|
||||
Icon=computer-laptop
|
||||
Exec=framework-laptop-tools
|
||||
Categories=Qt;KDE;Settings;HardwareSettings;
|
||||
@@ -0,0 +1,13 @@
|
||||
[Domain]
|
||||
Name=Framework Laptop Tools
|
||||
Icon=computer-laptop
|
||||
|
||||
[se.ajpanton.frameworktools.inspect]
|
||||
Name=Read Framework firmware settings
|
||||
Description=Read the battery charge limit and power-button brightness
|
||||
Policy=yes
|
||||
|
||||
[se.ajpanton.frameworktools.configure]
|
||||
Name=Configure Framework laptop hardware
|
||||
Description=Change Framework laptop lighting, cooling, charging or CPU limits
|
||||
Policy=auth_admin
|
||||
@@ -0,0 +1,89 @@
|
||||
Name: framework-laptop-tools
|
||||
Version: 0.1.0
|
||||
Release: 10%{?dist}
|
||||
Summary: Hardware controls and monitoring for Framework Laptop 13 Pro
|
||||
License: MIT
|
||||
URL: https://git.ajpanton.se/ajp_anton/fedora-tools
|
||||
Source0: %{name}-%{version}.tar.gz
|
||||
Source1: LICENSE
|
||||
BuildRequires: cmake
|
||||
BuildRequires: gcc-c++
|
||||
BuildRequires: extra-cmake-modules
|
||||
BuildRequires: qt6-qtbase-devel
|
||||
BuildRequires: kf6-kauth-devel
|
||||
BuildRequires: systemd-devel
|
||||
BuildRequires: systemd-rpm-macros
|
||||
Requires: polkit
|
||||
Requires: upower
|
||||
Provides: fedora-tools-tool
|
||||
|
||||
%description
|
||||
Native desktop and tray application for Framework Laptop 13 Pro (Intel Core
|
||||
Ultra Series 3). Monitor frequency, temperatures, battery and fan speed;
|
||||
configure lighting, fan overrides, battery charging and CPU frequency limits.
|
||||
Other hardware is rejected. Installation does not change hardware settings.
|
||||
|
||||
%prep
|
||||
%autosetup
|
||||
%build
|
||||
%cmake
|
||||
%cmake_build
|
||||
%check
|
||||
%ctest
|
||||
%install
|
||||
%cmake_install
|
||||
install -Dpm 0644 %{SOURCE1} %{buildroot}%{_licensedir}/%{name}/LICENSE
|
||||
%post
|
||||
%systemd_post framework-laptop-tools-fan.service framework-laptop-tools-fan-resume.service
|
||||
%preun
|
||||
%systemd_preun framework-laptop-tools-fan.service framework-laptop-tools-fan-resume.service framework-laptop-tools-cpu.service
|
||||
%postun
|
||||
%systemd_postun_with_restart framework-laptop-tools-fan.service framework-laptop-tools-cpu.service
|
||||
%files
|
||||
%license %{_licensedir}/%{name}/LICENSE
|
||||
%doc README.md
|
||||
%{_bindir}/framework-laptop-tools
|
||||
%{_libexecdir}/framework-laptop-tools-fan
|
||||
%{_libexecdir}/framework-laptop-tools-cpu
|
||||
%{_libexecdir}/kf6/kauth/framework-laptop-tools-helper
|
||||
%{_datadir}/applications/se.ajpanton.framework-laptop-tools.desktop
|
||||
%{_datadir}/fedora-tools/settings/framework-laptop-tools.json
|
||||
%{_datadir}/dbus-1/system-services/se.ajpanton.frameworktools.service
|
||||
%{_datadir}/dbus-1/system.d/se.ajpanton.frameworktools.conf
|
||||
%{_datadir}/polkit-1/actions/se.ajpanton.frameworktools.policy
|
||||
%{_unitdir}/framework-laptop-tools-fan.service
|
||||
%{_unitdir}/framework-laptop-tools-fan-resume.service
|
||||
%{_unitdir}/framework-laptop-tools-cpu.service
|
||||
%changelog
|
||||
* Fri Sep 11 2026 fedora-tools contributors - 0.1.0-10
|
||||
- Reduce chart unit spacing without overlapping time labels
|
||||
* Fri Sep 11 2026 fedora-tools contributors - 0.1.0-9
|
||||
- Keep chart timelines fixed between samples and align units with time labels
|
||||
- Simplify the Cooling section heading and note
|
||||
* Fri Sep 11 2026 fedora-tools contributors - 0.1.0-8
|
||||
- Stage settings across tabs with shared Save and Apply and Undo controls
|
||||
- Allow zero fan duty with risk confirmation; persist saved fan settings by default
|
||||
- Refine graph headings and units, inline sensor expansion and linked hover guides
|
||||
* Thu Sep 10 2026 fedora-tools contributors - 0.1.0-7
|
||||
- Add fan history, perceptual graph colours and live settings refresh
|
||||
- Add saved fan curves and separate P-core/E-core frequency overrides
|
||||
- Refine tray graph ranges, monitor layout and British English labels
|
||||
* Thu Sep 10 2026 fedora-tools contributors - 0.1.0-6
|
||||
- Make CPU frequency, governor and EPP overrides independent with hands-off Auto
|
||||
- Show live CPU status and contextual governor compatibility warnings
|
||||
- Draw tray colour swatches without theme icon tinting
|
||||
* Thu Sep 10 2026 fedora-tools contributors - 0.1.0-5
|
||||
- Improve legend, grid, tick and hover rendering
|
||||
- Share monitor history with configurable tray graphs and per-sensor ranges
|
||||
- Add staged automatic power-button lighting and clearer disabled CPU overrides
|
||||
* Thu Sep 10 2026 fedora-tools contributors - 0.1.0-4
|
||||
- Add adaptive graph axes, clickable legends, hover readings and battery state bands
|
||||
- Add battery rate history and configurable tray graphs or numeric readings
|
||||
- Clarify live CPU limits and saved overrides; compact control buttons
|
||||
* Tue Sep 08 2026 fedora-tools contributors - 0.1.0-3
|
||||
- Add sliders with precise numeric entry for bounded hardware settings
|
||||
* Tue Sep 08 2026 fedora-tools contributors - 0.1.0-2
|
||||
- Separate control tabs and saved battery/AC CPU profiles
|
||||
- Apply frequency bounds separately to each CPU policy
|
||||
* Tue Sep 08 2026 fedora-tools contributors - 0.1.0-1
|
||||
- Initial experimental Framework Laptop 13 Pro controls and monitoring
|
||||
@@ -0,0 +1,261 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#include "chart.h"
|
||||
#include <QPainter>
|
||||
#include <QPainterPath>
|
||||
#include <QMouseEvent>
|
||||
#include <QToolTip>
|
||||
#include <QVector3D>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
|
||||
namespace {
|
||||
double niceStep(double value)
|
||||
{
|
||||
const double magnitude = std::pow(10., std::floor(std::log10(value)));
|
||||
for (double factor : {1., 2., 5., 10.}) if (factor * magnitude >= value) return factor * magnitude;
|
||||
return 10 * magnitude;
|
||||
}
|
||||
QString number(double value) { return QString::number(value, 'f', std::abs(value) < 10 && value != std::round(value) ? 1 : 0); }
|
||||
// OKLab approximates perceptual distance. Greedy farthest-point selection leaves
|
||||
// existing colours untouched and fills the largest remaining gap each time.
|
||||
QVector3D perceptual(const QColor &c)
|
||||
{
|
||||
auto linear = [](double v) { return v <= .04045 ? v / 12.92 : std::pow((v + .055) / 1.055, 2.4); };
|
||||
const double r = linear(c.redF()), g = linear(c.greenF()), b = linear(c.blueF());
|
||||
const double l = std::cbrt(.4122214708*r + .5363325363*g + .0514459929*b);
|
||||
const double m = std::cbrt(.2119034982*r + .6806995451*g + .1073969566*b);
|
||||
const double s = std::cbrt(.0883024619*r + .2817188376*g + .6299787005*b);
|
||||
return {float(.2104542553*l + .793617785*m - .0040720468*s),
|
||||
float(1.9779984951*l - 2.428592205*m + .4505937099*s),
|
||||
float(.0259040371*l + .7827717662*m - .808675766*s)};
|
||||
}
|
||||
QColor nextColour(const QList<QColor> &used)
|
||||
{
|
||||
if (used.isEmpty()) return QColor("#3daee9");
|
||||
QList<QVector3D> positions;
|
||||
for (const auto &colour : used) positions.append(perceptual(colour));
|
||||
QColor best; double distance = -1;
|
||||
for (int h = 0; h < 360; h += 3) for (int s : {160, 210, 255}) for (int v : {185, 220, 250}) {
|
||||
const auto candidate = QColor::fromHsv(h, s, v);
|
||||
const auto p = perceptual(candidate);
|
||||
if (p.x() < .58 || p.x() > .82) continue; // Readable on light and dark backgrounds.
|
||||
double nearest = 10;
|
||||
for (const auto &other : positions) nearest = std::min(nearest, double((p - other).lengthSquared()));
|
||||
if (nearest > distance) { distance = nearest; best = candidate; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
}
|
||||
AxisTicks AxisTicks::covering(double minimum, double maximum, int intervals)
|
||||
{
|
||||
if (maximum <= minimum) maximum = minimum + 1;
|
||||
const double step = niceStep((maximum - minimum) / std::max(1, intervals));
|
||||
return {std::floor(minimum / step) * step, std::ceil(maximum / step) * step, step};
|
||||
}
|
||||
double timeTickStep(double spanMs, int intervals)
|
||||
{
|
||||
const double target = spanMs / 1000 / std::max(1, intervals);
|
||||
for (double seconds : {1., 2., 5., 10., 15., 30., 60., 120., 300., 600., 900., 1800., 3600., 7200., 10800., 21600., 43200., 86400.})
|
||||
if (seconds >= target) return seconds * 1000;
|
||||
return niceStep(target / 86400) * 86400000;
|
||||
}
|
||||
QString ageLabel(double milliseconds)
|
||||
{
|
||||
const qint64 seconds = qRound64(milliseconds / 1000);
|
||||
if (!seconds) return "Now";
|
||||
if (seconds % 3600 == 0) return QString::number(seconds / 3600) + "h";
|
||||
if (seconds % 60 == 0) return QString::number(seconds / 60) + "min";
|
||||
return QString::number(seconds) + "s";
|
||||
}
|
||||
Chart::Chart(const QString &unit, QWidget *parent) : QWidget(parent), m_unit(unit)
|
||||
{
|
||||
setMinimumHeight(175);
|
||||
setMouseTracking(true);
|
||||
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
|
||||
}
|
||||
void Chart::addSeries(const QString &id, const QString &name, const QString &unit)
|
||||
{
|
||||
m_series.insert(id, {QColor(),
|
||||
name.isEmpty() ? id : name, unit.isEmpty() ? m_unit : unit, {}});
|
||||
}
|
||||
void Chart::setSelected(const QString &id, bool selected)
|
||||
{
|
||||
if (!m_series.contains(id) || selected == m_selected.contains(id)) return;
|
||||
if (selected) {
|
||||
QList<QColor> used;
|
||||
for (const auto &key : m_selected) used.append(m_series[key].color);
|
||||
m_series[id].color = nextColour(used);
|
||||
m_selected.insert(id);
|
||||
} else m_selected.remove(id);
|
||||
update();
|
||||
}
|
||||
QColor Chart::color(const QString &id) const { return m_series.value(id).color; }
|
||||
QVector<QPointF> Chart::history(const QString &id) const
|
||||
{
|
||||
QVector<QPointF> points;
|
||||
for (const auto &point : m_series.value(id).points) {
|
||||
if (!points.isEmpty() && (point.x() - points.last().x() > m_interval * 3 || crossesSleep(points.last().x(), point.x())))
|
||||
points.append({point.x(), NAN});
|
||||
points.append(point);
|
||||
}
|
||||
return points;
|
||||
}
|
||||
void Chart::sample(const QMap<QString, double> &values, int intervalMs, qint64 now)
|
||||
{
|
||||
m_interval = intervalMs;
|
||||
m_sampleTime = now;
|
||||
double oldest = now;
|
||||
for (auto it = m_series.begin(); it != m_series.end(); ++it) {
|
||||
it->points.append({double(now), values.value(it.key(), std::numeric_limits<double>::quiet_NaN())});
|
||||
if (it->points.size() > 600) it->points.remove(0, it->points.size() - 600);
|
||||
oldest = std::min(oldest, it->points.first().x());
|
||||
}
|
||||
m_bands.removeIf([oldest](const Band &band) { return band.end && band.end < oldest; });
|
||||
update();
|
||||
}
|
||||
void Chart::setPowerState(bool sleeping, std::optional<bool> plugged, qint64 now)
|
||||
{
|
||||
// Charger state cannot be observed during suspend; don't extend AC bands through it.
|
||||
const std::optional<BandType> next = sleeping ? std::optional(BandType::Sleep)
|
||||
: plugged.value_or(false) ? std::optional(BandType::Plugged) : std::nullopt;
|
||||
if (!m_bands.isEmpty() && !m_bands.last().end) {
|
||||
if (next == m_bands.last().type) return;
|
||||
m_bands.last().end = now;
|
||||
}
|
||||
if (next) m_bands.append({now, 0, *next});
|
||||
update();
|
||||
}
|
||||
bool Chart::crossesSleep(double from, double to) const
|
||||
{
|
||||
for (const auto &band : m_bands)
|
||||
if (band.type == BandType::Sleep && band.begin < to && (!band.end || band.end > from)) return true;
|
||||
return false;
|
||||
}
|
||||
Chart::Frame Chart::frame() const
|
||||
{
|
||||
// Hover repaints must not move the data or open a gap after the latest sample.
|
||||
const double now = m_sampleTime;
|
||||
double first = now, low = 0, high = m_unit == "%" ? 100 : 1, low2 = 0, high2 = 1;
|
||||
bool secondary = false;
|
||||
const auto fm = fontMetrics();
|
||||
int unitWidth = fm.horizontalAdvance(m_unit);
|
||||
for (auto it = m_series.cbegin(); it != m_series.cend(); ++it) {
|
||||
if (!it->points.isEmpty()) first = std::min(first, it->points.first().x());
|
||||
if (!selected(it.key())) continue;
|
||||
const bool right = it->unit != m_unit;
|
||||
secondary |= right;
|
||||
unitWidth = std::max(unitWidth, fm.horizontalAdvance(it->unit));
|
||||
for (const auto &point : it->points) if (std::isfinite(point.y())) {
|
||||
(right ? low2 : low) = std::min(right ? low2 : low, point.y());
|
||||
(right ? high2 : high) = std::max(right ? high2 : high, point.y());
|
||||
}
|
||||
}
|
||||
const int timeTextWidth = fm.horizontalAdvance("100min");
|
||||
const int margin = std::max(fm.horizontalAdvance("−99999") + 10, unitWidth + timeTextWidth / 2 + 8);
|
||||
const QRectF area(margin, fm.height() / 2 + 4, width() - margin - (secondary ? margin : 25), height() - 2 * fm.height() - 20);
|
||||
const int intervals = std::max(2, int(area.height()) / (fm.height() * 2));
|
||||
return {area, std::min(first, now - 10000), now, AxisTicks::covering(low, high, intervals),
|
||||
AxisTicks::covering(low2, high2, intervals), secondary};
|
||||
}
|
||||
QString Chart::readingAt(qint64 time) const
|
||||
{
|
||||
QStringList lines{QDateTime::fromMSecsSinceEpoch(time).toString("ddd HH:mm:ss")};
|
||||
bool sleeping = false;
|
||||
for (const auto &band : m_bands) if (time >= band.begin && (!band.end || time < band.end)) {
|
||||
sleeping |= band.type == BandType::Sleep;
|
||||
lines.append(band.type == BandType::Sleep ? "Asleep · charger state unknown" : "Charger connected");
|
||||
}
|
||||
for (auto it = m_series.cbegin(); it != m_series.cend(); ++it) {
|
||||
if (!selected(it.key())) continue;
|
||||
const auto &points = it->points;
|
||||
auto next = std::lower_bound(points.cbegin(), points.cend(), double(time), [](const QPointF &p, double t) { return p.x() < t; });
|
||||
const QPointF *closest = next != points.cend() ? &*next : nullptr;
|
||||
if (next != points.cbegin() && (!closest || time - (next - 1)->x() < closest->x() - time)) closest = &*(next - 1);
|
||||
const bool valid = !sleeping && closest && std::abs(closest->x() - time) <= m_interval * 1.5
|
||||
&& std::isfinite(closest->y()) && !crossesSleep(std::min(closest->x(), double(time)), std::max(closest->x(), double(time)));
|
||||
lines.append(it->name + ": " + (valid ? number(closest->y()) + " " + it->unit : "—"));
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
void Chart::mouseMoveEvent(QMouseEvent *event)
|
||||
{
|
||||
const auto f = frame();
|
||||
if (!f.area.contains(event->position())) { setHoverTime(-1); Q_EMIT hovered(-1); QToolTip::hideText(); return; }
|
||||
const qint64 time = qRound64(f.first + (event->position().x() - f.area.left()) / f.area.width() * (f.last - f.first));
|
||||
setHoverTime(time); Q_EMIT hovered(time);
|
||||
QToolTip::showText(event->globalPosition().toPoint() + QPoint(12, 16), readingAt(time), this);
|
||||
}
|
||||
void Chart::leaveEvent(QEvent *event) { setHoverTime(-1); Q_EMIT hovered(-1); QToolTip::hideText(); QWidget::leaveEvent(event); }
|
||||
void Chart::paintEvent(QPaintEvent *)
|
||||
{
|
||||
QPainter p(this); p.setRenderHint(QPainter::Antialiasing);
|
||||
const auto f = frame(); const auto area = f.area;
|
||||
if (area.width() <= 0 || area.height() <= 0) return;
|
||||
const auto x = [&](double time) { return area.left() + (time - f.first) / (f.last - f.first) * area.width(); };
|
||||
const auto y = [&](double value, const AxisTicks &axis) { return area.bottom() - (value - axis.minimum) / (axis.maximum - axis.minimum) * area.height(); };
|
||||
const auto text = palette().color(QPalette::Text);
|
||||
QColor grid = text; grid.setAlpha(70);
|
||||
const bool dark = palette().color(QPalette::Window).lightness() < 128;
|
||||
p.save(); p.setClipRect(area);
|
||||
for (const auto &band : m_bands) {
|
||||
const QColor shade = band.type == BandType::Sleep ? (dark ? QColor(95, 130, 160, 65) : QColor(105, 140, 175, 45))
|
||||
: (dark ? QColor(210, 95, 100, 40) : QColor(210, 70, 80, 30));
|
||||
const double left = x(band.begin), right = x(band.end ? band.end : f.last);
|
||||
p.fillRect(QRectF(left, area.top(), right - left, area.height()), shade);
|
||||
p.setPen(QColor(shade.red(), shade.green(), shade.blue(), 130));
|
||||
p.drawLine(QPointF(left, area.top()), QPointF(left, area.bottom()));
|
||||
p.drawLine(QPointF(right, area.top()), QPointF(right, area.bottom()));
|
||||
}
|
||||
p.restore();
|
||||
const int h = fontMetrics().height();
|
||||
auto axis = [&](const AxisTicks &ticks, bool right) {
|
||||
for (double value = ticks.minimum; value <= ticks.maximum + ticks.step / 2; value += ticks.step) {
|
||||
const double pos = y(value, ticks);
|
||||
p.setPen(QPen(grid, 1, right ? Qt::DotLine : Qt::SolidLine));
|
||||
p.drawLine(QPointF(area.left(), pos), QPointF(area.right(), pos));
|
||||
p.setPen(text);
|
||||
p.drawLine(QPointF(right ? area.right() : area.left() - 6, pos), QPointF(right ? area.right() + 6 : area.left(), pos));
|
||||
p.drawText(QRectF(right ? area.right() + 7 : 0, pos - h / 2., area.left() - 8, h),
|
||||
(right ? Qt::AlignLeft : Qt::AlignRight) | Qt::AlignVCenter, number(value));
|
||||
}
|
||||
};
|
||||
axis(f.left, false); if (f.secondary) axis(f.right, true);
|
||||
const int labelWidth = fontMetrics().horizontalAdvance("100min") + 24;
|
||||
// Clear the time label's text, not its wider tick-spacing rectangle.
|
||||
const double unitGap = fontMetrics().horizontalAdvance("100min") / 2. + 4;
|
||||
p.drawText(QRectF(0, area.bottom() + 7, area.left() - unitGap, h), Qt::AlignRight, m_unit);
|
||||
if (f.secondary) {
|
||||
for (auto it = m_series.cbegin(); it != m_series.cend(); ++it) if (selected(it.key()) && it->unit != m_unit) {
|
||||
p.drawText(QRectF(area.right() + unitGap, area.bottom() + 7, width() - area.right() - unitGap, h), Qt::AlignLeft, it->unit); break;
|
||||
}
|
||||
}
|
||||
const double step = timeTickStep(f.last - f.first, std::max(1, int(area.width()) / labelWidth));
|
||||
for (double age = 0; age <= f.last - f.first; age += step) {
|
||||
const double pos = x(f.last - age);
|
||||
p.setPen(grid); p.drawLine(QPointF(pos, area.top()), QPointF(pos, area.bottom()));
|
||||
p.setPen(text); p.drawLine(QPointF(pos, area.bottom()), QPointF(pos, area.bottom() + 6));
|
||||
p.setPen(text); p.drawText(QRectF(pos - labelWidth / 2., area.bottom() + 7, labelWidth, h), Qt::AlignHCenter, ageLabel(age));
|
||||
}
|
||||
p.save(); p.setClipRect(area.adjusted(-1, -1, 1, 1));
|
||||
bool hasValues = false;
|
||||
for (auto it = m_series.cbegin(); it != m_series.cend(); ++it) {
|
||||
if (!selected(it.key())) continue;
|
||||
QPainterPath line; bool connected = false; double previous = 0;
|
||||
for (const auto &point : it->points) {
|
||||
if (!std::isfinite(point.y())) { connected = false; continue; }
|
||||
hasValues = true;
|
||||
const QPointF position(x(point.x()), y(point.y(), it->unit == m_unit ? f.left : f.right));
|
||||
if (connected && point.x() - previous <= m_interval * 3 && !crossesSleep(previous, point.x())) line.lineTo(position);
|
||||
else line.moveTo(position);
|
||||
connected = true; previous = point.x();
|
||||
}
|
||||
p.setPen(QPen(it->color, 2)); p.drawPath(line);
|
||||
}
|
||||
p.restore();
|
||||
if (m_hoverTime && *m_hoverTime >= f.first && *m_hoverTime <= f.last) {
|
||||
p.setPen(QPen(text, 1, Qt::DashLine));
|
||||
p.drawLine(QPointF(x(*m_hoverTime), area.top()), QPointF(x(*m_hoverTime), area.bottom()));
|
||||
}
|
||||
if (!hasValues) { p.setPen(text); p.drawText(area, Qt::AlignCenter, "No selected readings available"); }
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#pragma once
|
||||
#include <QWidget>
|
||||
#include <QDateTime>
|
||||
#include <QMap>
|
||||
#include <QSet>
|
||||
#include <optional>
|
||||
|
||||
struct AxisTicks {
|
||||
double minimum, maximum, step;
|
||||
static AxisTicks covering(double minimum, double maximum, int intervals);
|
||||
};
|
||||
double timeTickStep(double spanMs, int intervals);
|
||||
QString ageLabel(double milliseconds);
|
||||
|
||||
class Chart : public QWidget {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit Chart(const QString &unit, QWidget *parent = nullptr);
|
||||
void addSeries(const QString &id, const QString &name = {}, const QString &unit = {});
|
||||
void setSelected(const QString &id, bool selected);
|
||||
bool selected(const QString &id) const { return m_selected.contains(id); }
|
||||
void sample(const QMap<QString, double> &values, int intervalMs,
|
||||
qint64 now = QDateTime::currentMSecsSinceEpoch());
|
||||
void setPowerState(bool sleeping, std::optional<bool> plugged,
|
||||
qint64 now = QDateTime::currentMSecsSinceEpoch());
|
||||
QString readingAt(qint64 time) const;
|
||||
QColor color(const QString &id) const;
|
||||
QVector<QPointF> history(const QString &id) const;
|
||||
void setHoverTime(qint64 time) { m_hoverTime = time < 0 ? std::nullopt : std::optional<qint64>(time); update(); }
|
||||
std::optional<qint64> hoverTime() const { return m_hoverTime; }
|
||||
Q_SIGNALS:
|
||||
void hovered(qint64 time);
|
||||
protected:
|
||||
void paintEvent(QPaintEvent *) override;
|
||||
void mouseMoveEvent(QMouseEvent *event) override;
|
||||
void leaveEvent(QEvent *event) override;
|
||||
private:
|
||||
struct Series { QColor color; QString name, unit; QVector<QPointF> points; };
|
||||
enum class BandType { Sleep, Plugged };
|
||||
struct Band { qint64 begin, end; BandType type; };
|
||||
struct Frame { QRectF area; double first, last; AxisTicks left, right; bool secondary; };
|
||||
Frame frame() const;
|
||||
bool crossesSleep(double from, double to) const;
|
||||
QMap<QString, Series> m_series;
|
||||
QSet<QString> m_selected;
|
||||
QVector<Band> m_bands;
|
||||
QString m_unit;
|
||||
int m_interval = 1000;
|
||||
qint64 m_sampleTime = QDateTime::currentMSecsSinceEpoch();
|
||||
std::optional<qint64> m_hoverTime;
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#include "colorbutton.h"
|
||||
#include <QStylePainter>
|
||||
#include <QStyleOptionButton>
|
||||
|
||||
void ColorButton::setColor(const QColor &color)
|
||||
{
|
||||
m_color = color;
|
||||
setText(color.name() + (color.alpha() < 255 ? QString(" (%1% opacity)").arg(qRound(color.alphaF() * 100)) : QString()));
|
||||
setToolTip("Swatch shows the RGB colour without transparency; opacity is shown separately.");
|
||||
updateGeometry(); update();
|
||||
}
|
||||
QSize ColorButton::sizeHint() const
|
||||
{
|
||||
return QPushButton::sizeHint().expandedTo(QSize(fontMetrics().horizontalAdvance(text()) + 52, 30));
|
||||
}
|
||||
void ColorButton::paintEvent(QPaintEvent *)
|
||||
{
|
||||
QStylePainter p(this); QStyleOptionButton option; initStyleOption(&option);
|
||||
option.text.clear(); p.drawControl(QStyle::CE_PushButton, option);
|
||||
const QRect contents = style()->subElementRect(QStyle::SE_PushButtonContents, &option, this);
|
||||
const QRect swatch(contents.left() + 6, contents.center().y() - 8, 22, 16);
|
||||
QColor rgb = m_color; rgb.setAlpha(255);
|
||||
p.fillRect(swatch, rgb); p.setPen(palette().color(QPalette::Mid)); p.drawRect(swatch);
|
||||
p.setPen(palette().color(QPalette::ButtonText));
|
||||
p.drawText(contents.adjusted(36, 0, -6, 0), Qt::AlignLeft | Qt::AlignVCenter, text());
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#pragma once
|
||||
#include <QPushButton>
|
||||
|
||||
class ColorButton : public QPushButton {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit ColorButton(QWidget *parent = nullptr) : QPushButton(parent) {}
|
||||
void setColor(const QColor &color);
|
||||
QColor color() const { return m_color; }
|
||||
QSize sizeHint() const override;
|
||||
protected:
|
||||
void paintEvent(QPaintEvent *) override;
|
||||
private:
|
||||
QColor m_color;
|
||||
};
|
||||
@@ -0,0 +1,163 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#include "cpu.h"
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QJsonDocument>
|
||||
#include <QRegularExpression>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
QStringList cpuOptions(const QString &attribute, const QString &sys)
|
||||
{
|
||||
const QDir root(sys + "/devices/system/cpu/cpufreq");
|
||||
QStringList result;
|
||||
bool first = true;
|
||||
for (const auto &entry : root.entryList({"policy*"}, QDir::Dirs | QDir::NoDotAndDotDot)) {
|
||||
const auto options = readText(root.filePath(entry + "/" + attribute)).split(QRegularExpression("\\s+"), Qt::SkipEmptyParts);
|
||||
if (first) { result = options; first = false; }
|
||||
else result.removeIf([&](const auto &option) { return !options.contains(option); });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
QVariantMap normalizeCpuConfig(const QVariantMap &input)
|
||||
{
|
||||
if (input.isEmpty()) return {};
|
||||
const bool legacy = input.contains("enabled"), enabled = input.value("enabled").toBool();
|
||||
auto profile = [&](const QVariantMap &p) {
|
||||
QVariantMap result{{"frequencyOverride", legacy ? enabled : p.value("frequencyOverride", false).toBool()},
|
||||
{"minimum", p.value("minimum", 400)}, {"maximum", p.value("maximum", 5000)},
|
||||
{"governor", legacy && !enabled ? "auto" : p.value("governor", "auto")},
|
||||
{"preference", legacy && !enabled ? "auto" : p.value("preference", "auto")}};
|
||||
if (p.contains("bounds")) result["bounds"] = p["bounds"];
|
||||
return result;
|
||||
};
|
||||
QVariantMap result{{"separate", input.value("separate").toBool()}, {"battery", profile(input.value("battery").toMap())}};
|
||||
if (result["separate"].toBool()) result["ac"] = profile(input.value("ac").toMap());
|
||||
return result;
|
||||
}
|
||||
bool hasCpuOverrides(const QVariantMap &config)
|
||||
{
|
||||
for (const auto &key : {QString("battery"), QString("ac")}) {
|
||||
if (key == "ac" && !config.value("separate").toBool()) continue;
|
||||
const auto p = config.value(key).toMap();
|
||||
for (const auto &bound : p.value("bounds").toMap()) if (bound.toMap().value("frequencyOverride").toBool()) return true;
|
||||
if (p.value("frequencyOverride").toBool() || p.value("governor", "auto") != "auto" || p.value("preference", "auto") != "auto") return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
QString effectiveCpuGovernor(const QString &requested, const QString &preference, const QString &driver)
|
||||
{
|
||||
return driver == "intel_pstate" && requested == "performance" && preference != "performance" && preference != "0"
|
||||
? QString("powersave") : requested;
|
||||
}
|
||||
QString validateCpuProfile(const QVariantMap &profile, const QString &sys)
|
||||
{
|
||||
if (profile.contains("bounds")) {
|
||||
const auto groups = cpuPolicyGroups(sys);
|
||||
const auto bounds = profile["bounds"].toMap();
|
||||
for (auto it = bounds.cbegin(); it != bounds.cend(); ++it) {
|
||||
const auto p = it.value().toMap();
|
||||
if (!p.value("frequencyOverride").toBool()) continue;
|
||||
if (!groups.contains(it.key())) return "CPU core groups unavailable; refresh the settings.";
|
||||
const auto limits = cpuLimits(sys, it.key());
|
||||
bool a, b; const double lo = p["minimum"].toDouble(&a), hi = p["maximum"].toDouble(&b);
|
||||
if (limits.isEmpty() || !a || !b || !std::isfinite(lo) || !std::isfinite(hi) || lo != std::round(lo) || hi != std::round(hi)
|
||||
|| lo < limits["low"].toDouble() || hi > limits["high"].toDouble() || lo > hi) return "Invalid core-group frequency range.";
|
||||
}
|
||||
}
|
||||
if (profile.value("frequencyOverride").toBool()) {
|
||||
const auto limits = cpuLimits(sys);
|
||||
bool minOk, maxOk;
|
||||
const double minimum = profile.value("minimum").toDouble(&minOk), maximum = profile.value("maximum").toDouble(&maxOk);
|
||||
if (limits.isEmpty() || !minOk || !maxOk || !std::isfinite(minimum) || !std::isfinite(maximum)
|
||||
|| minimum != std::round(minimum) || maximum != std::round(maximum)
|
||||
|| minimum < limits["low"].toInt() || maximum > limits["high"].toInt() || minimum > maximum)
|
||||
return "Invalid CPU frequency range.";
|
||||
}
|
||||
const QString governor = profile.value("governor", "auto").toString(), preference = profile.value("preference", "auto").toString();
|
||||
if (governor != "auto" && !cpuOptions("scaling_available_governors", sys).contains(governor)) return "Unsupported CPU governor.";
|
||||
if (preference != "auto" && !cpuOptions("energy_performance_available_preferences", sys).contains(preference)) return "Unsupported energy preference.";
|
||||
return {};
|
||||
}
|
||||
QString validateCpuConfig(const QVariantMap &config, const QString &sys)
|
||||
{
|
||||
if (!config.contains("separate") || !config.contains("battery")) return "Incomplete CPU configuration.";
|
||||
auto error = validateCpuProfile(config.value("battery").toMap(), sys);
|
||||
if (error.isEmpty() && config.value("separate").toBool()) {
|
||||
if (!config.contains("ac")) return "Missing AC CPU profile.";
|
||||
error = validateCpuProfile(config.value("ac").toMap(), sys);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
QVariantMap cpuProfile(const QVariantMap &config, bool ac)
|
||||
{
|
||||
return config.value(ac && config.value("separate").toBool() ? "ac" : "battery").toMap();
|
||||
}
|
||||
QString applyCpuProfile(const QVariantMap &profile, const QString &sys)
|
||||
{
|
||||
auto error = validateCpuProfile(profile, sys);
|
||||
if (!error.isEmpty()) return error;
|
||||
const QString requested = profile.value("governor", "auto").toString(), preference = profile.value("preference", "auto").toString();
|
||||
if (requested != "auto" || preference != "auto") {
|
||||
const QDir root(sys + "/devices/system/cpu/cpufreq");
|
||||
const auto policies = root.entryList({"policy*"}, QDir::Dirs | QDir::NoDotAndDotDot);
|
||||
if (policies.isEmpty()) return "CPU policies unavailable.";
|
||||
QVector<QPair<QString, QString>> writes;
|
||||
for (const auto &entry : policies) {
|
||||
const QString path = root.filePath(entry + "/");
|
||||
const QString currentGovernor = readText(path + "scaling_governor");
|
||||
const QString currentPreference = readText(path + "energy_performance_preference");
|
||||
const QString driver = readText(path + "scaling_driver");
|
||||
if (currentGovernor.isEmpty() || currentPreference.isEmpty() || driver.isEmpty()) return "CPU policy readings unavailable.";
|
||||
const QString desiredPreference = preference == "auto" ? currentPreference : preference;
|
||||
const QString governor = effectiveCpuGovernor(requested, desiredPreference, driver);
|
||||
if (driver == "intel_pstate" && governor == "auto" && currentGovernor == "performance"
|
||||
&& preference != "auto" && preference != "performance")
|
||||
return "The current Performance governor blocks this energy preference. Select Powersave or leave energy preference on Auto.";
|
||||
// Auto never writes its attribute. Explicit governor choices may have kernel EPP side effects.
|
||||
if (governor != "auto" && governor != currentGovernor) writes.append({path + "scaling_governor", governor});
|
||||
if (preference != "auto") writes.append({path + "energy_performance_preference", preference});
|
||||
}
|
||||
for (const auto &[path, value] : writes) {
|
||||
if (readText(path) == value) continue;
|
||||
QFile file(path); const auto data = value.toUtf8() + '\n';
|
||||
if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size() || !file.flush())
|
||||
return "CPU settings may be partially applied: " + path + ": " + file.errorString();
|
||||
}
|
||||
}
|
||||
if (profile.contains("bounds")) {
|
||||
const auto bounds = profile["bounds"].toMap();
|
||||
for (auto it = bounds.cbegin(); it != bounds.cend(); ++it) {
|
||||
const auto p = it.value().toMap();
|
||||
if (!p.value("frequencyOverride").toBool()) continue;
|
||||
const auto error = setCpuLimits(p["minimum"].toInt(), p["maximum"].toInt(), sys, it.key());
|
||||
if (!error.isEmpty()) return error;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
return profile.value("frequencyOverride").toBool()
|
||||
? setCpuLimits(profile["minimum"].toInt(), profile["maximum"].toInt(), sys) : QString();
|
||||
}
|
||||
std::optional<bool> onAcPower(const QString &sys)
|
||||
{
|
||||
const QDir root(sys + "/class/power_supply");
|
||||
bool known = false;
|
||||
for (const auto &entry : root.entryList(QDir::Dirs | QDir::NoDotAndDotDot)) {
|
||||
if (readText(root.filePath(entry + "/type")) == "Battery") continue;
|
||||
const auto online = readNumber(root.filePath(entry + "/online"));
|
||||
if (online) { known = true; if (*online > 0) return true; }
|
||||
}
|
||||
return known ? std::optional<bool>(false) : std::nullopt;
|
||||
}
|
||||
QVariantMap readCpuConfig(QString &error)
|
||||
{
|
||||
QFile file(cpuConfigPath);
|
||||
if (!file.exists()) return {};
|
||||
if (!file.open(QIODevice::ReadOnly)) { error = file.errorString(); return {}; }
|
||||
QJsonParseError parse;
|
||||
const auto document = QJsonDocument::fromJson(file.readAll(), &parse);
|
||||
if (parse.error != QJsonParseError::NoError || !document.isObject()) {
|
||||
error = "Invalid saved CPU configuration: " + parse.errorString(); return {};
|
||||
}
|
||||
return normalizeCpuConfig(document.toVariant().toMap());
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#pragma once
|
||||
#include "hardware.h"
|
||||
|
||||
inline const QString cpuConfigPath = QStringLiteral("/etc/framework-laptop-tools/cpu.json");
|
||||
QStringList cpuOptions(const QString &attribute, const QString &sys = QStringLiteral("/sys"));
|
||||
QString validateCpuProfile(const QVariantMap &profile, const QString &sys = QStringLiteral("/sys"));
|
||||
QString validateCpuConfig(const QVariantMap &config, const QString &sys = QStringLiteral("/sys"));
|
||||
QVariantMap cpuProfile(const QVariantMap &config, bool ac);
|
||||
QVariantMap normalizeCpuConfig(const QVariantMap &input);
|
||||
bool hasCpuOverrides(const QVariantMap &config);
|
||||
QString effectiveCpuGovernor(const QString &requested, const QString &preference, const QString &driver);
|
||||
QString applyCpuProfile(const QVariantMap &profile, const QString &sys = QStringLiteral("/sys"));
|
||||
std::optional<bool> onAcPower(const QString &sys = QStringLiteral("/sys"));
|
||||
QVariantMap readCpuConfig(QString &error);
|
||||
@@ -0,0 +1,163 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#include "cpupage.h"
|
||||
#include <QFormLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QVBoxLayout>
|
||||
#include <QPushButton>
|
||||
|
||||
QVariantMap CpuPage::Editor::value() const
|
||||
{
|
||||
QVariantMap result{{"frequencyOverride", frequency->isChecked() && bounds.isEmpty()}, {"minimum", minimum->value()}, {"maximum", maximum->value()},
|
||||
{"governor", governor->currentText()}, {"preference", preference->currentText()}};
|
||||
if (!bounds.isEmpty()) {
|
||||
QVariantMap groups;
|
||||
for (auto it = bounds.cbegin(); it != bounds.cend(); ++it) groups[it.key()] = QVariantMap{
|
||||
{"frequencyOverride", it->enabled->isChecked()}, {"minimum", it->minimum->value()}, {"maximum", it->maximum->value()}};
|
||||
result["bounds"] = groups;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
void CpuPage::Editor::setValue(const QVariantMap &value)
|
||||
{
|
||||
minimum->setValue(value["minimum"].toInt()); maximum->setValue(value["maximum"].toInt());
|
||||
frequency->setChecked(value.value("frequencyOverride").toBool());
|
||||
minimum->setEnabled(frequency->isChecked()); maximum->setEnabled(frequency->isChecked());
|
||||
governor->setCurrentText(value.value("governor", "auto").toString()); preference->setCurrentText(value.value("preference", "auto").toString());
|
||||
for (auto it = bounds.cbegin(); it != bounds.cend(); ++it) {
|
||||
const auto p = value.contains("bounds") ? value["bounds"].toMap().value(it.key()).toMap() : value;
|
||||
it->minimum->setValue(p.value("minimum", it->minimum->minimum()).toInt());
|
||||
it->maximum->setValue(p.value("maximum", it->maximum->maximum()).toInt());
|
||||
it->enabled->setChecked(p.value("frequencyOverride").toBool());
|
||||
it->minimum->setEnabled(it->enabled->isChecked()); it->maximum->setEnabled(it->enabled->isChecked());
|
||||
}
|
||||
}
|
||||
CpuPage::Editor CpuPage::makeEditor(const QString &name)
|
||||
{
|
||||
Editor editor{new QGroupBox(name), new QCheckBox("Override frequency bounds"),
|
||||
new ValueControl, new ValueControl, new QComboBox, new QComboBox, new QLabel};
|
||||
editor.frequency->setObjectName("overrideFrequency");
|
||||
editor.governor->setObjectName("cpuGovernor"); editor.preference->setObjectName("cpuPreference");
|
||||
editor.warning->setWordWrap(true); editor.warning->setObjectName("governorWarning");
|
||||
auto *layout = new QFormLayout(editor.group);
|
||||
const auto limits = cpuLimits();
|
||||
layout->addRow(editor.frequency);
|
||||
editor.frequency->setToolTip("When unchecked, this tool does not write frequency limits. Existing limits are left unchanged.");
|
||||
for (auto *box : {editor.minimum, editor.maximum}) {
|
||||
box->setRange(limits.value("low", 400).toInt(), limits.value("high", 5000).toInt());
|
||||
box->setSuffix(" MHz"); box->setSingleStep(100);
|
||||
connect(box, &ValueControl::valueChanged, this, [this] { changed(); });
|
||||
}
|
||||
editor.governor->addItem("auto"); editor.preference->addItem("auto");
|
||||
editor.governor->addItems(cpuOptions("scaling_available_governors"));
|
||||
editor.preference->addItems(cpuOptions("energy_performance_available_preferences"));
|
||||
for (auto *combo : {editor.governor, editor.preference}) {
|
||||
combo->setToolTip("Auto leaves this setting to Linux or other controllers; it does not restore a preset. Save and apply commits changes.");
|
||||
connect(combo, &QComboBox::currentTextChanged, this, [this] { changed(); });
|
||||
}
|
||||
layout->addRow("Minimum frequency:", editor.minimum); layout->addRow("Maximum frequency:", editor.maximum);
|
||||
const auto groups = cpuPolicyGroups();
|
||||
if (!groups.isEmpty()) {
|
||||
layout->setRowVisible(editor.frequency, false); layout->setRowVisible(editor.minimum, false); layout->setRowVisible(editor.maximum, false);
|
||||
for (const QString &key : {QString("p"), QString("e")}) {
|
||||
Editor::Bounds b{new QCheckBox(key == "p" ? "Override P-core frequency bounds" : "Override E-core frequency bounds"), new ValueControl, new ValueControl};
|
||||
const auto limits = cpuLimits("/sys", key);
|
||||
for (auto *box : {b.minimum, b.maximum}) {
|
||||
box->setRange(limits["low"].toInt(), limits["high"].toInt()); box->setSuffix(" MHz"); box->setSingleStep(100);
|
||||
connect(box, &ValueControl::valueChanged, this, [this] { changed(); });
|
||||
}
|
||||
b.enabled->setObjectName("overrideFrequency_" + key);
|
||||
layout->addRow(b.enabled); layout->addRow("Minimum frequency:", b.minimum); layout->addRow("Maximum frequency:", b.maximum);
|
||||
connect(b.enabled, &QCheckBox::toggled, this, [this, b](bool enabled) { b.minimum->setEnabled(enabled); b.maximum->setEnabled(enabled); changed(); });
|
||||
editor.bounds.insert(key, b);
|
||||
}
|
||||
}
|
||||
layout->addRow("Energy preference:", editor.preference);
|
||||
auto *row = new QWidget; auto *line = new QHBoxLayout(row); line->setContentsMargins(0, 0, 0, 0);
|
||||
editor.governor->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
|
||||
line->addWidget(editor.governor); line->addWidget(editor.warning, 1); line->addStretch();
|
||||
layout->addRow("CPU governor:", row);
|
||||
connect(editor.frequency, &QCheckBox::toggled, this, [this, editor](bool enabled) {
|
||||
editor.minimum->setEnabled(enabled); editor.maximum->setEnabled(enabled); changed();
|
||||
});
|
||||
return editor;
|
||||
}
|
||||
CpuPage::CpuPage(QWidget *parent) : QWidget(parent)
|
||||
{
|
||||
auto *layout = new QVBoxLayout(this);
|
||||
m_status = new QLabel; m_status->setWordWrap(true); layout->addWidget(m_status);
|
||||
m_separate = new QCheckBox("Use separate profiles for battery power and AC");
|
||||
m_separate->setObjectName("separateCpuProfiles"); layout->addWidget(m_separate);
|
||||
m_separate->setToolTip("Splitting copies settings to both sides. Joining keeps the battery settings, committed only with Save and apply.");
|
||||
auto *columns = new QHBoxLayout;
|
||||
m_battery = makeEditor("CPU settings"); m_ac = makeEditor("AC power");
|
||||
m_battery.group->setObjectName("batteryCpuProfile"); m_ac.group->setObjectName("acCpuProfile");
|
||||
columns->addWidget(m_battery.group, 1); columns->addWidget(m_ac.group, 1); layout->addLayout(columns);
|
||||
layout->addStretch();
|
||||
connect(m_separate, &QCheckBox::toggled, this, &CpuPage::split);
|
||||
load({}, true);
|
||||
}
|
||||
void CpuPage::changed()
|
||||
{
|
||||
if (!m_loading) {
|
||||
// Joining hides the AC draft; it must survive refresh until saved or undone.
|
||||
const auto savedAc = m_saved.value(m_saved.value("separate").toBool() ? "ac" : "battery").toMap();
|
||||
m_dirty = draft() != m_saved || (m_hasAcDraft && !m_separate->isChecked() && m_ac.value() != savedAc);
|
||||
refreshStatus(); Q_EMIT draftChanged();
|
||||
}
|
||||
}
|
||||
void CpuPage::split(bool separate)
|
||||
{
|
||||
if (separate && !m_hasAcDraft) { m_ac.setValue(m_battery.value()); m_hasAcDraft = true; }
|
||||
m_ac.group->setVisible(separate); m_battery.group->setTitle(separate ? "Battery power" : "CPU settings");
|
||||
changed();
|
||||
}
|
||||
QVariantMap CpuPage::draft() const
|
||||
{
|
||||
QVariantMap config{{"separate", m_separate->isChecked()}, {"battery", m_battery.value()}};
|
||||
if (m_separate->isChecked()) config["ac"] = m_ac.value();
|
||||
return config;
|
||||
}
|
||||
void CpuPage::load(const QVariantMap &config, bool force)
|
||||
{
|
||||
if (m_dirty && !force) return;
|
||||
m_loading = true;
|
||||
auto saved = normalizeCpuConfig(config);
|
||||
if (saved.isEmpty()) {
|
||||
const auto limits = cpuLimits();
|
||||
saved = {{"separate", false}, {"battery", QVariantMap{{"frequencyOverride", false},
|
||||
{"minimum", limits.value("min", 400)}, {"maximum", limits.value("max", 5000)},
|
||||
{"governor", "auto"}, {"preference", "auto"}}}};
|
||||
}
|
||||
m_saved = saved;
|
||||
m_battery.setValue(saved["battery"].toMap());
|
||||
m_hasAcDraft = saved["separate"].toBool();
|
||||
if (m_hasAcDraft) m_ac.setValue(saved["ac"].toMap());
|
||||
else m_ac.setValue(saved["battery"].toMap());
|
||||
m_separate->setChecked(m_hasAcDraft); split(m_hasAcDraft);
|
||||
m_saved = draft(); m_loading = false; m_dirty = false; refreshStatus(); Q_EMIT draftChanged();
|
||||
}
|
||||
void CpuPage::refreshStatus()
|
||||
{
|
||||
const auto limits = cpuLimits(); const auto ac = onAcPower();
|
||||
m_status->setText(limits.isEmpty() ? "Live CPU readings unavailable." :
|
||||
QString("Live: %1\nEnergy preference: %3\nGovernor: %2\nFrequency bounds: %4–%5 MHz (highest across policies); hardware maximum: %6 MHz.%7")
|
||||
.arg(ac ? (*ac ? "AC power" : "battery power") : "power source unknown", limits["governor"].toString(), limits["preference"].toString())
|
||||
.arg(limits["min"].toInt()).arg(limits["max"].toInt()).arg(limits["high"].toInt())
|
||||
.arg(m_dirty ? "\nUnsaved changes." : ""));
|
||||
auto warning = [&](Editor &editor, bool active) {
|
||||
QString text;
|
||||
const auto p = editor.value();
|
||||
const QString governor = p["governor"].toString(), pref = p["preference"].toString();
|
||||
if (readText("/sys/devices/system/cpu/cpufreq/policy0/scaling_driver") == "intel_pstate") {
|
||||
if (governor == "performance" && pref == "auto")
|
||||
text = "⚠ Requires performance EPP; may fall back to powersave.";
|
||||
else if (governor == "performance" && pref != "performance")
|
||||
text = "⚠ Requires performance EPP; will fall back to powersave.";
|
||||
else if (governor == "auto" && pref != "auto" && pref != "performance" && active && limits["governor"] == "performance")
|
||||
text = "⚠ Current Performance governor blocks this EPP. Select Powersave or leave EPP on Auto.";
|
||||
}
|
||||
editor.warning->setText(text); editor.warning->setVisible(!text.isEmpty());
|
||||
};
|
||||
warning(m_battery, !m_separate->isChecked() || (ac && !*ac));
|
||||
warning(m_ac, m_separate->isChecked() && ac && *ac);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#pragma once
|
||||
#include "cpu.h"
|
||||
#include <QWidget>
|
||||
#include <QCheckBox>
|
||||
#include <QComboBox>
|
||||
#include "valuecontrol.h"
|
||||
#include <QGroupBox>
|
||||
#include <QLabel>
|
||||
|
||||
class CpuPage : public QWidget {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit CpuPage(QWidget *parent = nullptr);
|
||||
QVariantMap draft() const;
|
||||
void load(const QVariantMap &config, bool force = false);
|
||||
bool dirty() const { return m_dirty; }
|
||||
void refreshStatus();
|
||||
void undo() { load(m_saved, true); }
|
||||
Q_SIGNALS:
|
||||
void draftChanged();
|
||||
private:
|
||||
struct Editor {
|
||||
struct Bounds { QCheckBox *enabled; ValueControl *minimum, *maximum; };
|
||||
QGroupBox *group;
|
||||
QCheckBox *frequency;
|
||||
ValueControl *minimum, *maximum;
|
||||
QComboBox *governor, *preference;
|
||||
QLabel *warning;
|
||||
QMap<QString, Bounds> bounds;
|
||||
QVariantMap value() const;
|
||||
void setValue(const QVariantMap &value);
|
||||
};
|
||||
Editor makeEditor(const QString &name);
|
||||
void split(bool separate);
|
||||
void changed();
|
||||
QCheckBox *m_separate;
|
||||
Editor m_battery, m_ac;
|
||||
QLabel *m_status;
|
||||
QVariantMap m_saved;
|
||||
bool m_dirty = false, m_loading = false, m_hasAcDraft = false;
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#include "cpu.h"
|
||||
#include <QCoreApplication>
|
||||
#include <QDBusConnection>
|
||||
#include <QDebug>
|
||||
#include <QTimer>
|
||||
#include <QDir>
|
||||
#include <systemd/sd-daemon.h>
|
||||
|
||||
class CpuWorker : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
CpuWorker() {
|
||||
m_apply.setSingleShot(true); m_apply.setInterval(2000);
|
||||
connect(&m_apply, &QTimer::timeout, this, &CpuWorker::apply);
|
||||
connect(&m_poll, &QTimer::timeout, this, [this] {
|
||||
const auto ac = onAcPower();
|
||||
if (ac != m_ac) { m_ac = ac; m_apply.start(); }
|
||||
const auto profile = cpuProfile(m_config, ac.value_or(false));
|
||||
if (profile.value("governor") == "performance" && profile.value("preference") == "auto") {
|
||||
const auto now = preferences();
|
||||
if (now != m_preferences) { m_preferences = now; m_apply.start(); }
|
||||
}
|
||||
sd_notify(0, "WATCHDOG=1");
|
||||
});
|
||||
auto bus = QDBusConnection::systemBus();
|
||||
bus.connect("com.redhat.tuned", "/Tuned", "com.redhat.tuned.control", "profile_changed", this, SLOT(profileChanged(QString,bool,QString)));
|
||||
bus.connect("org.freedesktop.login1", "/org/freedesktop/login1", "org.freedesktop.login1.Manager", "PrepareForSleep", this, SLOT(sleepChanged(bool)));
|
||||
m_poll.start(2000); m_apply.start();
|
||||
}
|
||||
private Q_SLOTS:
|
||||
void profileChanged(const QString &, bool success, const QString &) { if (success) m_apply.start(); }
|
||||
void sleepChanged(bool sleeping) { if (!sleeping) m_apply.start(); }
|
||||
void apply() {
|
||||
QString error;
|
||||
const auto config = readCpuConfig(error);
|
||||
if (error.isEmpty()) error = validateCpuConfig(config);
|
||||
const auto ac = onAcPower();
|
||||
if (error.isEmpty() && !ac && config.value("separate").toBool()) error = "Cannot determine the power source.";
|
||||
if (error.isEmpty()) error = applyCpuProfile(cpuProfile(config, ac.value_or(false)));
|
||||
if (!error.isEmpty()) { qCritical().noquote() << error; QCoreApplication::exit(1); return; }
|
||||
m_ac = ac;
|
||||
m_config = config; m_preferences = preferences();
|
||||
qInfo() << "Applied saved CPU profile:" << (ac.value_or(false) ? "AC" : "battery/shared");
|
||||
sd_notify(0, "READY=1\nWATCHDOG=1");
|
||||
}
|
||||
private:
|
||||
QStringList preferences() const {
|
||||
const QDir root("/sys/devices/system/cpu/cpufreq"); QStringList values;
|
||||
for (const auto &entry : root.entryList({"policy*"}, QDir::Dirs | QDir::NoDotAndDotDot))
|
||||
values.append(readText(root.filePath(entry + "/energy_performance_preference")));
|
||||
return values;
|
||||
}
|
||||
QTimer m_poll, m_apply;
|
||||
std::optional<bool> m_ac;
|
||||
QVariantMap m_config;
|
||||
QStringList m_preferences;
|
||||
};
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
QCoreApplication app(argc, argv);
|
||||
auto error = compatibilityError();
|
||||
if (!error.isEmpty()) { qCritical().noquote() << error; return 1; }
|
||||
CpuWorker worker;
|
||||
return app.exec();
|
||||
}
|
||||
#include "cpuservice.moc"
|
||||
@@ -0,0 +1,98 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#include "fan.h"
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonDocument>
|
||||
#include <cmath>
|
||||
|
||||
QVariantMap readFanConfig(QString &error)
|
||||
{
|
||||
QFile file(QFile::exists(fanConfigPath) ? fanConfigPath : savedFanConfigPath);
|
||||
if (!file.exists()) return {};
|
||||
if (!file.open(QIODevice::ReadOnly)) { error = file.errorString(); return {}; }
|
||||
QJsonParseError parse;
|
||||
const auto doc = QJsonDocument::fromJson(file.readAll(), &parse);
|
||||
auto config = doc.toVariant().toMap();
|
||||
error = parse.error == QJsonParseError::NoError ? validateFan(config) : "Invalid fan configuration.";
|
||||
return config;
|
||||
}
|
||||
QVector<double> fanTemperatures(const QVariantMap &config)
|
||||
{
|
||||
if (!config.contains("temperatures")) return {40, 55, 70, 85};
|
||||
QVector<double> result;
|
||||
for (const auto &value : config["temperatures"].toList()) {
|
||||
bool ok; const double n = value.toDouble(&ok);
|
||||
if (!ok || !std::isfinite(n)) return {};
|
||||
result.append(n);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
QString validateFan(const QVariantMap &config)
|
||||
{
|
||||
const auto mode = config.value("mode").toString();
|
||||
if (mode == "auto") return {};
|
||||
if (mode != "manual" && mode != "curve") return "Unknown fan mode.";
|
||||
const auto values = mode == "manual" ? QVariantList{config.value("duty")} : config.value("curve").toList();
|
||||
if (values.size() != (mode == "manual" ? 1 : 4)) return "A fan curve needs four points.";
|
||||
double previous = 0;
|
||||
for (const auto &value : values) {
|
||||
bool ok;
|
||||
const double n = value.toDouble(&ok);
|
||||
if (!ok || !std::isfinite(n) || n < previous || n > 100) return "Fan percentages must be non-decreasing and between 0 and 100.";
|
||||
previous = n;
|
||||
}
|
||||
if (mode == "curve" && previous != 100) return "The final curve point must be 100%.";
|
||||
if (mode == "curve") {
|
||||
const auto temperatures = fanTemperatures(config);
|
||||
if (temperatures.size() != 4) return "A fan curve needs four temperatures.";
|
||||
double previousTemperature = 19;
|
||||
for (const double t : temperatures) {
|
||||
if (t <= previousTemperature || t > 85) return "Curve temperatures must increase, from 20 to 85 °C.";
|
||||
previousTemperature = t;
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
QString restoreFan()
|
||||
{
|
||||
if (!compatibilityError().isEmpty()) return compatibilityError();
|
||||
const auto path = ecHwmon();
|
||||
if (path.isEmpty()) return "EC fan interface unavailable.";
|
||||
return writeNumber(path + "/pwm1_enable", 2);
|
||||
}
|
||||
QString updateFan(const QVariantMap &config, double &lastDuty, const QString &sys)
|
||||
{
|
||||
const auto validation = validateFan(config);
|
||||
if (!validation.isEmpty()) return validation;
|
||||
if (config.value("mode") == "auto") return "Auto does not need a fan worker.";
|
||||
const auto path = ecHwmon(sys);
|
||||
if (path.isEmpty()) return "EC fan interface disappeared.";
|
||||
if (readNumber(path + "/fan1_fault") != std::optional<double>(0)) return "Fan reports a fault; returning to Auto.";
|
||||
double hottest = 0;
|
||||
for (int i = 1; i <= 5; ++i) {
|
||||
const QString stem = path + "/temp" + QString::number(i);
|
||||
const auto value = readNumber(stem + "_input");
|
||||
const auto fault = readNumber(stem + "_fault");
|
||||
const auto limit = readNumber(stem + "_crit");
|
||||
if (!value || !fault || *fault != 0 || !limit || *limit <= 0 || *value < 0 || *value >= *limit - 3000)
|
||||
return "Temperature unavailable or near a firmware thermal limit; returning to Auto.";
|
||||
hottest = std::max(hottest, *value / 1000);
|
||||
}
|
||||
if (hottest >= 90) return "Temperature reached 90 °C; returning to Auto.";
|
||||
double duty = config.value("duty").toDouble();
|
||||
if (config.value("mode") == "curve") {
|
||||
QVector<double> points;
|
||||
for (const auto &value : config.value("curve").toList()) points.append(value.toDouble());
|
||||
duty = curveDuty(hottest, points, fanTemperatures(config));
|
||||
// Increase immediately; lower gradually to avoid audible oscillation.
|
||||
if (lastDuty >= 0 && duty < lastDuty) duty = std::max(duty, lastDuty - 2);
|
||||
}
|
||||
if (readNumber(path + "/pwm1_enable") != std::optional<double>(1)) {
|
||||
const auto error = writeNumber(path + "/pwm1_enable", 1);
|
||||
if (!error.isEmpty()) return error;
|
||||
}
|
||||
const auto error = writeNumber(path + "/pwm1", qRound(duty * 255 / 100));
|
||||
if (error.isEmpty()) lastDuty = duty;
|
||||
return error;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#pragma once
|
||||
#include "hardware.h"
|
||||
QString validateFan(const QVariantMap &config);
|
||||
QString restoreFan();
|
||||
QString updateFan(const QVariantMap &config, double &lastDuty, const QString &sys = QStringLiteral("/sys"));
|
||||
inline const QString fanConfigPath = QStringLiteral("/run/framework-laptop-tools/fan.json");
|
||||
inline const QString savedFanConfigPath = QStringLiteral("/etc/framework-laptop-tools/fan.json");
|
||||
QVariantMap readFanConfig(QString &error);
|
||||
QVector<double> fanTemperatures(const QVariantMap &config);
|
||||
@@ -0,0 +1,43 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#include "fan.h"
|
||||
#include <QCoreApplication>
|
||||
#include <QFile>
|
||||
#include <QJsonDocument>
|
||||
#include <QTimer>
|
||||
#include <QDebug>
|
||||
#include <QProcess>
|
||||
#include <systemd/sd-daemon.h>
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
QCoreApplication app(argc, argv);
|
||||
if (app.arguments().contains("--resume")) {
|
||||
if (!QFile::exists(savedFanConfigPath)) return 0;
|
||||
if (QProcess::execute("/usr/bin/systemctl", {"--quiet", "is-enabled", "framework-laptop-tools-fan.service"}) != 0) return 0;
|
||||
return QProcess::execute("/usr/bin/systemctl", {"--no-block", "start", "framework-laptop-tools-fan.service"});
|
||||
}
|
||||
if (app.arguments().contains("--restore")) {
|
||||
if (!QFile::exists(fanConfigPath) && !QFile::exists(savedFanConfigPath)) return 0;
|
||||
const auto error = restoreFan();
|
||||
if (!error.isEmpty()) { qCritical().noquote() << error; return 1; }
|
||||
if (QFile::exists(fanConfigPath) && !QFile::remove(fanConfigPath)) { qCritical() << "Cannot remove fan runtime configuration"; return 1; }
|
||||
return 0;
|
||||
}
|
||||
const auto compatible = compatibilityError();
|
||||
if (!compatible.isEmpty()) { qCritical().noquote() << compatible; return 1; }
|
||||
double lastDuty = -1;
|
||||
bool ready = false;
|
||||
QTimer timer;
|
||||
auto tick = [&] {
|
||||
QString error;
|
||||
const auto config = readFanConfig(error);
|
||||
if (error.isEmpty()) error = updateFan(config, lastDuty);
|
||||
if (!error.isEmpty()) { qCritical().noquote() << error; app.exit(1); return; }
|
||||
if (!ready) { sd_notify(0, "READY=1"); ready = true; }
|
||||
sd_notify(0, "WATCHDOG=1");
|
||||
};
|
||||
QObject::connect(&timer, &QTimer::timeout, &app, tick);
|
||||
timer.start(1000);
|
||||
QTimer::singleShot(0, &app, tick);
|
||||
return app.exec();
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#include "hardware.h"
|
||||
#include <QDir>
|
||||
#include <QDirIterator>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QSet>
|
||||
#include <QtEndian>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <fcntl.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <unistd.h>
|
||||
|
||||
QString readText(const QString &path)
|
||||
{
|
||||
QFile file(path);
|
||||
return file.open(QIODevice::ReadOnly) ? QString::fromUtf8(file.readAll()).trimmed() : QString();
|
||||
}
|
||||
std::optional<double> readNumber(const QString &path)
|
||||
{
|
||||
bool ok;
|
||||
const double n = readText(path).toDouble(&ok);
|
||||
return ok && std::isfinite(n) ? std::optional<double>(n) : std::nullopt;
|
||||
}
|
||||
QString compatibilityError(const QString &sys)
|
||||
{
|
||||
if (readText(sys + "/class/dmi/id/sys_vendor") != "Framework"
|
||||
|| readText(sys + "/class/dmi/id/product_name") != "Laptop 13 Pro (Intel Core Ultra Series 3)")
|
||||
return "Supported hardware: Framework Laptop 13 Pro (Intel Core Ultra Series 3). Other models have not been validated.";
|
||||
return {};
|
||||
}
|
||||
QString ecHwmon(const QString &sys)
|
||||
{
|
||||
const QDir dir(sys + "/class/hwmon");
|
||||
for (const QString &name : dir.entryList({"hwmon*"}, QDir::Dirs | QDir::NoDotAndDotDot))
|
||||
if (readText(dir.filePath(name + "/name")) == "cros_ec") return dir.filePath(name);
|
||||
return {};
|
||||
}
|
||||
QVector<Sensor> temperatureSensors(const QString &sys)
|
||||
{
|
||||
QVector<Sensor> sensors;
|
||||
const QMap<QString, QString> friendly{{"local_f75397@4c", "Mainboard (EC)"},
|
||||
{"cpu_f75303@4d", "CPU area (EC)"}, {"battery_temp@b", "Battery (EC)"},
|
||||
{"ddr_f75303@4d", "Memory area (EC)"}, {"peci-temp", "CPU (PECI)"}};
|
||||
const QDir root(sys + "/class/hwmon");
|
||||
for (const auto &entry : root.entryList({"hwmon*"}, QDir::Dirs | QDir::NoDotAndDotDot)) {
|
||||
const QDir dir(root.filePath(entry));
|
||||
const QString chip = readText(dir.filePath("name"));
|
||||
for (const auto &file : dir.entryList({"temp*_input"}, QDir::Files)) {
|
||||
const QString stem = file.chopped(6);
|
||||
const QString raw = readText(dir.filePath(stem + "_label"));
|
||||
QString label = friendly.value(raw);
|
||||
bool primary = !label.isEmpty() && raw != "cpu_f75303@4d" && raw != "ddr_f75303@4d";
|
||||
if (chip == "nvme") {
|
||||
label = raw == "Composite" ? "NVMe (composite)" : "NVMe — " + raw;
|
||||
primary = raw == "Composite";
|
||||
} else if (chip == "spd5118") {
|
||||
label = "Memory (SPD)";
|
||||
primary = true;
|
||||
} else if (chip == "coretemp") {
|
||||
label = raw.startsWith("Package id") ? "CPU package " + raw.section(' ', -1) : "CPU " + raw.toLower();
|
||||
}
|
||||
if (label.isEmpty()) label = chip + " — " + (raw.isEmpty() ? stem : raw);
|
||||
// Retain raw identifiers in the ID/tooltip; don't invent sensor locations.
|
||||
sensors.append({chip + "/" + (raw.isEmpty() ? stem : raw), label, dir.filePath(file), "°C", .001, primary});
|
||||
}
|
||||
}
|
||||
return sensors;
|
||||
}
|
||||
QVector<Sensor> frequencySensors(const QString &sys)
|
||||
{
|
||||
QVector<Sensor> sensors;
|
||||
sensors.append({"cpu", "CPU average (reported)", sys + "/devices/system/cpu/cpufreq", "MHz", .001});
|
||||
const QDir drm(sys + "/class/drm");
|
||||
for (const auto &card : drm.entryList({"card*"}, QDir::Dirs | QDir::NoDotAndDotDot)) {
|
||||
QDirIterator files(drm.filePath(card + "/device"), {"act_freq"}, QDir::Files,
|
||||
QDirIterator::Subdirectories);
|
||||
while (files.hasNext()) {
|
||||
const QString path = files.next();
|
||||
const QString relative = path.mid(drm.filePath(card + "/device/").size());
|
||||
sensors.append({card + "/" + relative, "GPU " + card + " " + relative.section('/', 1, 1), path, "MHz", 1});
|
||||
}
|
||||
}
|
||||
return sensors;
|
||||
}
|
||||
std::optional<double> sensorValue(const Sensor &sensor)
|
||||
{
|
||||
if (sensor.id == "cpu") {
|
||||
const QDir dir(sensor.path);
|
||||
double total = 0;
|
||||
int count = 0;
|
||||
for (const auto &policy : dir.entryList({"policy*"}, QDir::Dirs | QDir::NoDotAndDotDot)) {
|
||||
const auto n = readNumber(dir.filePath(policy + "/scaling_cur_freq"));
|
||||
if (n) { total += *n; ++count; }
|
||||
}
|
||||
return count ? std::optional<double>(total / count * sensor.scale) : std::nullopt;
|
||||
}
|
||||
if (sensor.unit == "°C") {
|
||||
const QString fault = sensor.path.chopped(6) + "_fault";
|
||||
if (QFileInfo::exists(fault) && readNumber(fault) != std::optional<double>(0)) return {};
|
||||
}
|
||||
auto n = readNumber(sensor.path);
|
||||
if (!n) return {};
|
||||
*n *= sensor.scale;
|
||||
if (sensor.unit == "°C" && (*n < -20 || *n > 150)) return {};
|
||||
return n;
|
||||
}
|
||||
QVariantMap batteryStatus(const QString &sys)
|
||||
{
|
||||
const QDir root(sys + "/class/power_supply");
|
||||
for (const auto &entry : root.entryList(QDir::Dirs | QDir::NoDotAndDotDot)) {
|
||||
const QDir dir(root.filePath(entry));
|
||||
if (readText(dir.filePath("type")) != "Battery") continue;
|
||||
QVariantMap result{{"state", readText(dir.filePath("status"))}};
|
||||
for (const auto &name : {"capacity", "cycle_count", "voltage_now", "voltage_min_design", "charge_now", "charge_full", "charge_full_design", "energy_now", "energy_full", "energy_full_design", "power_now", "current_now"}) {
|
||||
const auto n = readNumber(dir.filePath(name));
|
||||
if (n) result.insert(name, *n);
|
||||
}
|
||||
if (result.contains("power_now")) result["watts"] = std::abs(result["power_now"].toDouble()) / 1e6;
|
||||
else if (result.contains("current_now") && result.contains("voltage_now"))
|
||||
result["watts"] = std::abs(result["current_now"].toDouble() * result["voltage_now"].toDouble()) / 1e12;
|
||||
if (result.contains("charge_full") && result["charge_full_design"].toDouble() > 0)
|
||||
result["health"] = 100 * result["charge_full"].toDouble() / result["charge_full_design"].toDouble();
|
||||
if (result.contains("energy_full")) {
|
||||
result["fullMWh"] = result["energy_full"].toDouble() / 1000;
|
||||
if (result["energy_full_design"].toDouble() > 0)
|
||||
result["health"] = 100 * result["energy_full"].toDouble() / result["energy_full_design"].toDouble();
|
||||
} else if (result.contains("charge_full") && result["voltage_min_design"].toDouble() > 0) {
|
||||
result["fullMWh"] = result["charge_full"].toDouble() * result["voltage_min_design"].toDouble() / 1e9;
|
||||
result["capacityEstimated"] = true;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
std::optional<double> batteryRate(const QVariantMap &battery)
|
||||
{
|
||||
if (!battery.contains("watts")) return {};
|
||||
const double watts = battery["watts"].toDouble();
|
||||
if (battery["state"] == "Charging") return watts;
|
||||
if (battery["state"] == "Discharging") return -watts;
|
||||
if (battery["state"] == "Full" || battery["state"] == "Not charging") return 0.;
|
||||
return {};
|
||||
}
|
||||
QMap<QString, QStringList> cpuPolicyGroups(const QString &sys)
|
||||
{
|
||||
QMap<QString, QSet<int>> cpus;
|
||||
for (const auto &kind : {QString("core"), QString("atom")}) {
|
||||
const auto list = readText(sys + "/bus/event_source/devices/cpu_" + kind + "/cpus");
|
||||
for (const auto &range : list.split(',', Qt::SkipEmptyParts)) {
|
||||
const auto ends = range.split('-'); bool a, b;
|
||||
const int first = ends.first().toInt(&a), last = ends.last().toInt(&b);
|
||||
if (!a || !b || first < 0 || last < first || last > 65535) return {};
|
||||
for (int cpu = first; cpu <= last; ++cpu) cpus[kind].insert(cpu);
|
||||
}
|
||||
}
|
||||
if (cpus["core"].isEmpty() || cpus["atom"].isEmpty()) return {};
|
||||
QMap<QString, QStringList> groups;
|
||||
const QDir root(sys + "/devices/system/cpu/cpufreq");
|
||||
for (const auto &policy : root.entryList({"policy*"}, QDir::Dirs | QDir::NoDotAndDotDot)) {
|
||||
const auto related = readText(root.filePath(policy + "/related_cpus")).simplified().split(' ', Qt::SkipEmptyParts);
|
||||
QString type;
|
||||
for (const auto &id : related) {
|
||||
bool ok; const int cpu = id.toInt(&ok);
|
||||
const QString candidate = cpus["core"].contains(cpu) ? "p" : cpus["atom"].contains(cpu) ? "e" : "";
|
||||
if (!ok || candidate.isEmpty() || (!type.isEmpty() && type != candidate)) return {};
|
||||
type = candidate;
|
||||
}
|
||||
if (type.isEmpty()) return {};
|
||||
groups[type].append(policy);
|
||||
}
|
||||
return groups.size() == 2 ? groups : QMap<QString, QStringList>{};
|
||||
}
|
||||
QVariantMap cpuLimits(const QString &sys, const QString &group)
|
||||
{
|
||||
const QDir root(sys + "/devices/system/cpu/cpufreq");
|
||||
const auto policies = group.isEmpty() ? root.entryList({"policy*"}, QDir::Dirs | QDir::NoDotAndDotDot) : cpuPolicyGroups(sys).value(group);
|
||||
double low = 0, high = 0, min = 0, max = 0;
|
||||
int count = 0, capped = 0, raised = 0;
|
||||
for (const auto &entry : policies) {
|
||||
auto a = readNumber(root.filePath(entry + "/cpuinfo_min_freq"));
|
||||
auto b = readNumber(root.filePath(entry + "/cpuinfo_max_freq"));
|
||||
auto c = readNumber(root.filePath(entry + "/scaling_min_freq"));
|
||||
auto d = readNumber(root.filePath(entry + "/scaling_max_freq"));
|
||||
if (!a || !b || !c || !d) return {};
|
||||
low = std::max(low, *a); high = std::max(high, *b);
|
||||
min = std::max(min, *c); max = std::max(max, *d); ++count;
|
||||
if (*d < *b) ++capped;
|
||||
if (*c > *a) ++raised;
|
||||
}
|
||||
if (!count) return {};
|
||||
return {{"low", low / 1000}, {"high", high / 1000}, {"min", min / 1000}, {"max", max / 1000},
|
||||
{"policies", count}, {"capped", capped}, {"raised", raised},
|
||||
{"governor", readText(root.filePath("policy0/scaling_governor"))},
|
||||
{"preference", readText(root.filePath("policy0/energy_performance_preference"))}};
|
||||
}
|
||||
QString writeNumber(const QString &path, qint64 value)
|
||||
{
|
||||
QFile file(path);
|
||||
const QByteArray data = QByteArray::number(value) + '\n';
|
||||
if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size() || !file.flush())
|
||||
return path + ": " + file.errorString();
|
||||
return {};
|
||||
}
|
||||
QString setCpuLimits(int minimumMHz, int maximumMHz, const QString &sys, const QString &group)
|
||||
{
|
||||
const auto limits = cpuLimits(sys, group);
|
||||
if (limits.isEmpty() || minimumMHz < limits["low"].toInt() || maximumMHz > limits["high"].toInt() || minimumMHz > maximumMHz)
|
||||
return "Invalid CPU frequency range.";
|
||||
const QDir root(sys + "/devices/system/cpu/cpufreq");
|
||||
const auto policies = group.isEmpty() ? root.entryList({"policy*"}, QDir::Dirs | QDir::NoDotAndDotDot) : cpuPolicyGroups(sys).value(group);
|
||||
for (const auto &entry : policies) {
|
||||
const QString path = root.filePath(entry);
|
||||
const auto currentMax = readNumber(path + "/scaling_max_freq");
|
||||
const auto hardwareMin = readNumber(path + "/cpuinfo_min_freq");
|
||||
const auto hardwareMax = readNumber(path + "/cpuinfo_max_freq");
|
||||
if (!currentMax || !hardwareMin || !hardwareMax) return "CPU policy disappeared; refresh and try again.";
|
||||
const int policyMin = std::clamp(minimumMHz * 1000, int(*hardwareMin), int(*hardwareMax));
|
||||
const int policyMax = std::clamp(maximumMHz * 1000, int(*hardwareMin), int(*hardwareMax));
|
||||
const bool raiseFirst = policyMin > *currentMax;
|
||||
const QString first = raiseFirst ? "/scaling_max_freq" : "/scaling_min_freq";
|
||||
QString error = writeNumber(path + first, raiseFirst ? policyMax : policyMin);
|
||||
if (error.isEmpty()) error = writeNumber(path + (raiseFirst ? "/scaling_min_freq" : "/scaling_max_freq"), raiseFirst ? policyMin : policyMax);
|
||||
if (!error.isEmpty()) return "Some policies may already have changed. " + error;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
double curveDuty(double temperature, const QVector<double> &duties, const QVector<double> &temperatures)
|
||||
{
|
||||
if (duties.size() != 4 || temperatures.size() != 4 || !std::isfinite(temperature)) return 100;
|
||||
if (temperature <= temperatures.first()) return duties.first();
|
||||
for (int i = 1; i < temperatures.size(); ++i) if (temperature <= temperatures[i])
|
||||
return duties[i - 1] + (duties[i] - duties[i - 1]) * (temperature - temperatures[i - 1]) / (temperatures[i] - temperatures[i - 1]);
|
||||
return duties.last();
|
||||
}
|
||||
|
||||
namespace {
|
||||
struct EcHeader { quint32 version, command, outsize, insize, result; };
|
||||
struct EcPacket { EcHeader header; unsigned char data[32]; };
|
||||
static_assert(sizeof(EcHeader) == 20);
|
||||
QByteArray ecCommand(quint32 command, quint32 version, const QByteArray &request, int responseSize, QString &error)
|
||||
{
|
||||
EcPacket packet{{version, command, quint32(request.size()), quint32(responseSize), 0}, {0}};
|
||||
if (request.size() > 32 || responseSize > 32) { error = "Invalid EC buffer size"; return {}; }
|
||||
std::memcpy(packet.data, request.constData(), request.size());
|
||||
const int fd = open("/dev/cros_ec", O_RDWR | O_CLOEXEC);
|
||||
if (fd < 0) { error = QString::fromLocal8Bit(strerror(errno)); return {}; }
|
||||
const int result = ioctl(fd, _IOWR(0xEC, 0, EcHeader), &packet);
|
||||
const int savedErrno = errno;
|
||||
close(fd);
|
||||
if (result < 0 || packet.header.result || result != responseSize) {
|
||||
error = QString("EC command 0x%1 failed (result %2, bytes %3): %4")
|
||||
.arg(command, 0, 16).arg(packet.header.result).arg(result)
|
||||
.arg(result < 0 ? QString::fromLocal8Bit(strerror(savedErrno)) : QString("unexpected response"));
|
||||
return {};
|
||||
}
|
||||
return QByteArray(reinterpret_cast<char *>(packet.data), result);
|
||||
}
|
||||
}
|
||||
QVariantMap firmwareStatus()
|
||||
{
|
||||
QVariantMap result;
|
||||
QString error;
|
||||
const auto led = ecCommand(0x3E0E, 1, QByteArray::fromHex("ffff"), 2, error);
|
||||
if (error.isEmpty()) { result["powerBrightness"] = quint8(led[0]); result["powerAuto"] = quint8(led[1]) == 255; }
|
||||
else result["powerError"] = error;
|
||||
error.clear();
|
||||
const auto charge = ecCommand(0x3E03, 0, QByteArray::fromHex("08ffff"), 2, error);
|
||||
if (error.isEmpty()) {
|
||||
const int limit = quint8(charge[0]) & 0x7f;
|
||||
result["chargeLimit"] = limit == 0 || limit == 127 ? 100 : limit;
|
||||
result["chargeOverride"] = (quint8(charge[0]) & 0x80) != 0;
|
||||
}
|
||||
else result["chargeError"] = error;
|
||||
return result;
|
||||
}
|
||||
QString setFirmwareValue(const QString &operation, int value)
|
||||
{
|
||||
QString error;
|
||||
if (operation == "powerBrightness" && value >= 1 && value <= 100) {
|
||||
QByteArray request(2, 0); request[0] = char(value);
|
||||
ecCommand(0x3E0E, 1, request, 0, error);
|
||||
}
|
||||
else if (operation == "powerAuto")
|
||||
ecCommand(0x3E0E, 0, QByteArray::fromHex("ff00"), 0, error);
|
||||
else if (operation == "chargeLimit" && value >= 50 && value <= 100) {
|
||||
const auto current = ecCommand(0x3E03, 0, QByteArray::fromHex("08ffff"), 2, error);
|
||||
if (error.isEmpty()) {
|
||||
QByteArray request = QByteArray::fromHex("020000");
|
||||
request[1] = char(value); request[2] = char(std::min<int>(quint8(current[1]), value));
|
||||
ecCommand(0x3E03, 0, request, 0, error);
|
||||
}
|
||||
} else if (operation == "chargeWatts" && value >= 0 && value <= 75) {
|
||||
const auto battery = batteryStatus();
|
||||
const double voltage = battery.value("voltage_now").toDouble() / 1e6;
|
||||
if (value != 0 && (voltage < 10 || voltage > 20)) return "Battery voltage unavailable or outside the supported range.";
|
||||
// The EC accepts mA, not watts. Zero here means restore its default limit.
|
||||
const quint32 milliamps = value == 0 ? 0xffffffff : quint32(value * 1000 / voltage);
|
||||
QByteArray request(4, 0);
|
||||
qToLittleEndian(milliamps, request.data());
|
||||
ecCommand(0xA1, 0, request, 0, error);
|
||||
} else return "Unsupported firmware setting or value.";
|
||||
return error;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#pragma once
|
||||
#include <QString>
|
||||
#include <QVariantMap>
|
||||
#include <QVector>
|
||||
#include <optional>
|
||||
|
||||
struct Sensor {
|
||||
QString id, name, path, unit;
|
||||
double scale = 1;
|
||||
bool primary = false;
|
||||
};
|
||||
QString readText(const QString &path);
|
||||
std::optional<double> readNumber(const QString &path);
|
||||
QString compatibilityError(const QString &sys = QStringLiteral("/sys"));
|
||||
QString ecHwmon(const QString &sys = QStringLiteral("/sys"));
|
||||
QVector<Sensor> temperatureSensors(const QString &sys = QStringLiteral("/sys"));
|
||||
QVector<Sensor> frequencySensors(const QString &sys = QStringLiteral("/sys"));
|
||||
QVariantMap batteryStatus(const QString &sys = QStringLiteral("/sys"));
|
||||
QMap<QString, QStringList> cpuPolicyGroups(const QString &sys = QStringLiteral("/sys"));
|
||||
QVariantMap cpuLimits(const QString &sys = QStringLiteral("/sys"), const QString &group = {});
|
||||
std::optional<double> sensorValue(const Sensor &sensor);
|
||||
std::optional<double> batteryRate(const QVariantMap &battery);
|
||||
double curveDuty(double temperature, const QVector<double> &duties, const QVector<double> &temperatures = {40, 55, 70, 85});
|
||||
QString writeNumber(const QString &path, qint64 value);
|
||||
QString setCpuLimits(int minimumMHz, int maximumMHz, const QString &sys = QStringLiteral("/sys"), const QString &group = {});
|
||||
QVariantMap firmwareStatus();
|
||||
QString setFirmwareValue(const QString &operation, int value);
|
||||
@@ -0,0 +1,129 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#include "hardware.h"
|
||||
#include "fan.h"
|
||||
#include "cpu.h"
|
||||
#include <KAuth/ActionReply>
|
||||
#include <KAuth/HelperSupport>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QJsonDocument>
|
||||
#include <QProcess>
|
||||
#include <QSaveFile>
|
||||
|
||||
class Helper : public QObject {
|
||||
Q_OBJECT
|
||||
KAuth::ActionReply reply(const QString &error, const QVariantMap &data = {}) {
|
||||
if (error.isEmpty()) { auto r = KAuth::ActionReply::SuccessReply(); r.setData(data); return r; }
|
||||
auto r = KAuth::ActionReply::HelperErrorReply(); r.setErrorDescription(error); return r;
|
||||
}
|
||||
QString service(const QString &verb, const QString &unit = "framework-laptop-tools-fan.service") {
|
||||
QProcess process;
|
||||
process.start("/usr/bin/systemctl", {verb, unit});
|
||||
if (!process.waitForFinished(25000)) { process.kill(); process.waitForFinished(); return "Service did not respond."; }
|
||||
return process.exitCode() == 0 ? QString() : QString::fromUtf8(process.readAllStandardError());
|
||||
}
|
||||
public Q_SLOTS:
|
||||
KAuth::ActionReply inspect(const QVariantMap &) {
|
||||
const auto error = compatibilityError();
|
||||
if (!error.isEmpty()) return reply(error);
|
||||
auto data = firmwareStatus();
|
||||
QString cpuError;
|
||||
data["cpuConfig"] = readCpuConfig(cpuError);
|
||||
if (!cpuError.isEmpty()) data["cpuError"] = cpuError;
|
||||
const auto path = ecHwmon();
|
||||
const auto mode = readNumber(path + "/pwm1_enable");
|
||||
if (mode == std::optional<double>(2)) data["fanMode"] = "auto";
|
||||
else if (mode == std::optional<double>(1)) {
|
||||
data["fanMode"] = "manual";
|
||||
const auto pwm = readNumber(path + "/pwm1");
|
||||
if (pwm) data["fanDuty"] = qRound(*pwm * 100 / 255);
|
||||
QString fanError;
|
||||
const auto config = readFanConfig(fanError);
|
||||
if (!fanError.isEmpty()) data["fanError"] = fanError;
|
||||
if (fanError.isEmpty()) {
|
||||
if (config.value("mode") == "curve") {
|
||||
data["fanMode"] = "curve";
|
||||
data["fanCurve"] = config["curve"];
|
||||
QVariantList temperatures;
|
||||
for (const double t : fanTemperatures(config)) temperatures.append(t);
|
||||
data["fanTemperatures"] = temperatures;
|
||||
}
|
||||
}
|
||||
}
|
||||
data["fanPersistent"] = QFile::exists(savedFanConfigPath);
|
||||
return reply({}, data);
|
||||
}
|
||||
KAuth::ActionReply configure(const QVariantMap &args) {
|
||||
QString error = compatibilityError();
|
||||
if (!error.isEmpty()) return reply(error);
|
||||
const QString operation = args.value("operation").toString();
|
||||
if (operation == "batch") {
|
||||
const auto operations = args.value("operations").toList();
|
||||
if (operations.isEmpty() || operations.size() > 6) return reply("Invalid settings batch.");
|
||||
const QStringList allowed{"keyboard", "powerAuto", "powerBrightness", "chargeLimit", "chargeWatts", "fan", "cpuProfiles"};
|
||||
for (const auto &entry : operations)
|
||||
if (!allowed.contains(entry.toMap().value("operation").toString())) return reply("Unknown operation in settings batch.");
|
||||
QVariantList completed;
|
||||
for (const auto &entry : operations) {
|
||||
const auto result = configure(entry.toMap());
|
||||
if (result.failed()) return reply({}, {{"completed", completed}, {"applyError", result.errorDescription()}});
|
||||
completed.append(entry);
|
||||
}
|
||||
return reply({}, {{"completed", completed}});
|
||||
}
|
||||
bool valid;
|
||||
const double raw = args.value("value").toDouble(&valid);
|
||||
const int value = valid && raw >= 0 && raw <= 10000 && raw == int(raw) ? int(raw) : -1;
|
||||
if (operation == "keyboard" && value >= 0 && value <= 100)
|
||||
error = writeNumber("/sys/class/leds/chromeos::kbd_backlight/brightness", value);
|
||||
else if (operation == "cpuProfiles") {
|
||||
const auto config = normalizeCpuConfig(args.value("config").toMap());
|
||||
error = validateCpuConfig(config);
|
||||
if (!error.isEmpty()) return reply(error);
|
||||
if (!QDir().mkpath("/etc/framework-laptop-tools")) return reply("Cannot create CPU configuration directory.");
|
||||
QSaveFile file(cpuConfigPath);
|
||||
if (!file.open(QIODevice::WriteOnly)) return reply(file.errorString());
|
||||
file.setPermissions(QFile::ReadOwner | QFile::WriteOwner);
|
||||
const auto data = QJsonDocument::fromVariant(config).toJson();
|
||||
if (file.write(data) != data.size() || !file.commit()) return reply(file.errorString());
|
||||
const QString unit = "framework-laptop-tools-cpu.service";
|
||||
if (hasCpuOverrides(config)) {
|
||||
error = service("enable", unit);
|
||||
if (error.isEmpty()) error = service("restart", unit);
|
||||
} else {
|
||||
error = service("disable", unit);
|
||||
if (error.isEmpty()) error = service("stop", unit);
|
||||
}
|
||||
if (!error.isEmpty()) error = "CPU settings were saved, but applying them failed: " + error;
|
||||
} else if (operation == "fan") {
|
||||
error = validateFan(args);
|
||||
if (!error.isEmpty()) return reply(error);
|
||||
// Restore the old worker before replacing its configuration.
|
||||
error = service("stop");
|
||||
if (!error.isEmpty()) return reply(error);
|
||||
if (QFile::exists(savedFanConfigPath) && !QFile::remove(savedFanConfigPath)) return reply("Cannot remove saved fan settings.");
|
||||
const bool persistent = args.value("mode") != "auto";
|
||||
error = service(persistent ? "enable" : "disable");
|
||||
if (error.isEmpty()) error = service(persistent ? "enable" : "disable", "framework-laptop-tools-fan-resume.service");
|
||||
if (!error.isEmpty()) return reply(error);
|
||||
if (args.value("mode") == "auto") error = restoreFan();
|
||||
else {
|
||||
const QString path = persistent ? savedFanConfigPath : fanConfigPath;
|
||||
if (!QDir().mkpath(QFileInfo(path).absolutePath())) error = "Cannot create fan settings directory.";
|
||||
if (error.isEmpty()) {
|
||||
QSaveFile file(path);
|
||||
if (!file.open(QIODevice::WriteOnly)) return reply(file.errorString());
|
||||
file.setPermissions(QFile::ReadOwner | QFile::WriteOwner);
|
||||
const auto data = QJsonDocument::fromVariant(args).toJson(QJsonDocument::Compact);
|
||||
if (file.write(data) != data.size() || !file.commit()) return reply(file.errorString());
|
||||
error = service("start");
|
||||
}
|
||||
}
|
||||
} else if (operation == "powerAuto") error = setFirmwareValue(operation, 0);
|
||||
else if (value >= 0) error = setFirmwareValue(operation, value);
|
||||
else error = "Unknown operation or invalid value.";
|
||||
return reply(error);
|
||||
}
|
||||
};
|
||||
KAUTH_HELPER_MAIN("se.ajpanton.frameworktools", Helper)
|
||||
#include "helper.moc"
|
||||
@@ -0,0 +1,52 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#include "legend.h"
|
||||
#include <QEvent>
|
||||
|
||||
Legend::Legend(Chart *chart, const QVector<Sensor> &sensors, QWidget *parent)
|
||||
: QLabel(parent), m_chart(chart), m_sensors(sensors)
|
||||
{
|
||||
setWordWrap(true); setTextFormat(Qt::RichText);
|
||||
setTextInteractionFlags(Qt::LinksAccessibleByMouse | Qt::LinksAccessibleByKeyboard);
|
||||
setToolTip("Click a sensor to show or hide it. Hover over the graph for readings.");
|
||||
connect(this, &QLabel::linkHovered, this, [this](const QString &link) {
|
||||
bool valid; const int index = link.toInt(&valid);
|
||||
setToolTip(valid && index >= 0 && index < m_sensors.size()
|
||||
? m_sensors[index].name + "\n" + m_sensors[index].path + "\nClick to show or hide."
|
||||
: "Click a sensor to show or hide it. Hover over the graph for readings.");
|
||||
});
|
||||
connect(this, &QLabel::linkActivated, this, [this](const QString &link) {
|
||||
if (link == "extra") { Q_EMIT extraActivated(); return; }
|
||||
bool valid; const int index = link.toInt(&valid);
|
||||
if (!valid || index < 0 || index >= m_sensors.size()) return;
|
||||
const QString id = m_sensors[index].id;
|
||||
const bool enabled = !m_chart->selected(id);
|
||||
m_chart->setSelected(id, enabled); refresh(); Q_EMIT selectionChanged(id, enabled);
|
||||
});
|
||||
refresh();
|
||||
}
|
||||
int Legend::enabledCount() const
|
||||
{
|
||||
int count = 0;
|
||||
for (const auto &sensor : m_sensors) if (m_chart->selected(sensor.id)) ++count;
|
||||
return count;
|
||||
}
|
||||
void Legend::refresh()
|
||||
{
|
||||
QStringList links;
|
||||
for (int i = 0; i < m_sensors.size(); ++i) {
|
||||
const auto &sensor = m_sensors[i]; const bool enabled = m_chart->selected(sensor.id);
|
||||
const auto gray = palette().color(QPalette::Disabled, QPalette::WindowText);
|
||||
const auto text = enabled ? palette().color(QPalette::WindowText) : gray;
|
||||
const auto dot = enabled ? m_chart->color(sensor.id) : gray;
|
||||
links.append(QString("<a href=\"%1\" style=\"color:%2;text-decoration:none\"><span style=\"color:%4\">●</span> <span style=\"text-decoration:%3\">%5</span></a>")
|
||||
.arg(i).arg(text.name(), enabled ? "none" : "line-through", dot.name(), sensor.name.toHtmlEscaped().replace(' ', " ")));
|
||||
}
|
||||
if (!m_extraLink.isEmpty()) links.append(QString("<a href=\"extra\" style=\"color:%1;text-decoration:none\">%2</a>")
|
||||
.arg(palette().color(QPalette::WindowText).name(), m_extraLink.toHtmlEscaped().replace(' ', " ")));
|
||||
setText(links.join(" "));
|
||||
}
|
||||
void Legend::changeEvent(QEvent *event)
|
||||
{
|
||||
QLabel::changeEvent(event);
|
||||
if (event->type() == QEvent::PaletteChange || event->type() == QEvent::ApplicationPaletteChange) refresh();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#pragma once
|
||||
#include "chart.h"
|
||||
#include "hardware.h"
|
||||
#include <QLabel>
|
||||
|
||||
// Rich-text links wrap naturally without widening the page for long sensor lists.
|
||||
class Legend : public QLabel {
|
||||
Q_OBJECT
|
||||
public:
|
||||
Legend(Chart *chart, const QVector<Sensor> &sensors, QWidget *parent = nullptr);
|
||||
int enabledCount() const;
|
||||
void setExtraLink(const QString &text) { m_extraLink = text; refresh(); }
|
||||
Q_SIGNALS:
|
||||
void selectionChanged(const QString &id, bool selected);
|
||||
void extraActivated();
|
||||
protected:
|
||||
void changeEvent(QEvent *event) override;
|
||||
private:
|
||||
void refresh();
|
||||
Chart *m_chart;
|
||||
QVector<Sensor> m_sensors;
|
||||
QString m_extraLink;
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#include "window.h"
|
||||
#include <QApplication>
|
||||
#include <QDBusConnection>
|
||||
#include <QDBusAbstractAdaptor>
|
||||
#include <QDBusInterface>
|
||||
|
||||
class WindowAdaptor : public QDBusAbstractAdaptor {
|
||||
Q_OBJECT
|
||||
Q_CLASSINFO("D-Bus Interface", "se.ajpanton.FrameworkLaptopTools")
|
||||
public:
|
||||
explicit WindowAdaptor(Window *window) : QDBusAbstractAdaptor(window), m_window(window) {}
|
||||
public Q_SLOTS:
|
||||
void Show() { m_window->show(); m_window->raise(); m_window->activateWindow(); }
|
||||
private:
|
||||
Window *m_window;
|
||||
};
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
QApplication app(argc, argv);
|
||||
app.setApplicationName("framework-laptop-tools");
|
||||
app.setDesktopFileName("se.ajpanton.framework-laptop-tools");
|
||||
auto bus = QDBusConnection::sessionBus();
|
||||
if (!bus.registerService("se.ajpanton.FrameworkLaptopTools")) {
|
||||
QDBusInterface existing("se.ajpanton.FrameworkLaptopTools", "/Window", "se.ajpanton.FrameworkLaptopTools");
|
||||
const auto response = existing.call("Show");
|
||||
return response.type() == QDBusMessage::ErrorMessage ? 1 : 0;
|
||||
}
|
||||
Window window;
|
||||
new WindowAdaptor(&window);
|
||||
bus.registerObject("/Window", &window, QDBusConnection::ExportAdaptors);
|
||||
if (!app.arguments().contains("--tray") || !QSystemTrayIcon::isSystemTrayAvailable()) window.show();
|
||||
return app.exec();
|
||||
}
|
||||
#include "main.moc"
|
||||
@@ -0,0 +1,79 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#include "tray.h"
|
||||
#include <QPainter>
|
||||
#include <QPainterPath>
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
|
||||
std::optional<double> CpuUsage::sample(const QString &procStat)
|
||||
{
|
||||
const auto fields = procStat.section('\n', 0, 0).simplified().split(' ');
|
||||
if (fields.size() < 5 || fields.first() != "cpu") { reset(); return {}; }
|
||||
Counters current{0, 0};
|
||||
// guest/guest_nice are already included in user/nice; sum only through steal.
|
||||
for (int i = 1; i < std::min(9, int(fields.size())); ++i) {
|
||||
bool ok; const auto value = fields[i].toULongLong(&ok);
|
||||
if (!ok) { reset(); return {}; }
|
||||
current.total += value;
|
||||
if (i == 4 || i == 5) current.idle += value;
|
||||
}
|
||||
const auto previous = m_previous; m_previous = current;
|
||||
if (!previous || current.total <= previous->total || current.idle < previous->idle) return {};
|
||||
const auto total = current.total - previous->total, idle = current.idle - previous->idle;
|
||||
if (idle > total) return {};
|
||||
return 100. * (total - idle) / total;
|
||||
}
|
||||
QRectF trayPlotRect(bool border)
|
||||
{
|
||||
// Two-pixel border; leave room for the graph's two-pixel stroke inside it.
|
||||
return border ? QRectF(3, 3, 58, 58) : QRectF(1, 1, 62, 62);
|
||||
}
|
||||
QIcon telemetryIcon(bool graph, const QVector<QPointF> &values, const QString &unit, const TrayStyle &style)
|
||||
{
|
||||
QPixmap pixmap(64, 64); pixmap.fill(style.transparent ? Qt::transparent : style.backgroundColor);
|
||||
QPainter p(&pixmap); p.setRenderHint(QPainter::Antialiasing);
|
||||
if (style.border) { p.setPen(QPen(style.borderColor, 2)); p.drawRect(QRectF(1, 1, 62, 62)); }
|
||||
const auto area = trayPlotRect(style.border);
|
||||
const double latest = values.isEmpty() ? NAN : values.last().y();
|
||||
p.setPen(style.lineColor);
|
||||
if (graph && !values.isEmpty() && style.maximum > style.minimum) {
|
||||
const double first = values.first().x(), span = std::max(1., values.last().x() - first);
|
||||
const auto position = [&](const QPointF &point) {
|
||||
const double fraction = std::clamp((point.y() - style.minimum) / (style.maximum - style.minimum), 0., 1.);
|
||||
return QPointF(area.left() + (point.x() - first) / span * area.width(), area.bottom() - fraction * area.height());
|
||||
};
|
||||
const auto outside = [&](double y) { return y < style.minimum || y > style.maximum; };
|
||||
const auto color = [&](bool out) { return out && style.overflowColor ? style.outsideColor : style.lineColor; };
|
||||
for (int i = 1; i < values.size(); ++i) {
|
||||
const auto a = values[i - 1], b = values[i];
|
||||
if (!std::isfinite(a.y()) || !std::isfinite(b.y())) continue;
|
||||
QVector<double> cuts{0, 1};
|
||||
if (a.y() != b.y()) for (double boundary : {style.minimum, style.maximum}) {
|
||||
const double t = (boundary - a.y()) / (b.y() - a.y());
|
||||
if (t > 0 && t < 1) cuts.append(t);
|
||||
}
|
||||
std::sort(cuts.begin(), cuts.end());
|
||||
for (int j = 1; j < cuts.size(); ++j) {
|
||||
const bool out = outside(a.y() + (b.y() - a.y()) * (cuts[j - 1] + cuts[j]) / 2);
|
||||
if (out && !style.clamp) continue;
|
||||
const auto left = position(a + (b - a) * cuts[j - 1]), right = position(a + (b - a) * cuts[j]);
|
||||
if (style.fill) {
|
||||
QPainterPath fill; fill.moveTo(left); fill.lineTo(right);
|
||||
fill.lineTo(right.x(), area.bottom()); fill.lineTo(left.x(), area.bottom()); fill.closeSubpath();
|
||||
p.fillPath(fill, style.fillColor);
|
||||
}
|
||||
p.setPen(QPen(color(out), 2, Qt::SolidLine, Qt::FlatCap)); p.drawLine(left, right);
|
||||
}
|
||||
}
|
||||
for (const auto &point : values) if (std::isfinite(point.y()) && (style.clamp || !outside(point.y()))) {
|
||||
p.setPen(QPen(color(outside(point.y())), 2, Qt::SolidLine, Qt::RoundCap)); p.drawPoint(position(point));
|
||||
}
|
||||
} else {
|
||||
const QString value = !std::isfinite(latest) ? "—" : unit == "MHz"
|
||||
? QString::number(latest / 1000, 'f', 1) : QString::number(std::round(latest), 'f', 0);
|
||||
QFont font; font.setBold(true); font.setPixelSize(42);
|
||||
while (QFontMetrics(font).horizontalAdvance(value) > area.width() && font.pixelSize() > 12) font.setPixelSize(font.pixelSize() - 1);
|
||||
p.setFont(font); p.drawText(area, Qt::AlignCenter, value);
|
||||
}
|
||||
return QIcon(pixmap);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#pragma once
|
||||
#include <QIcon>
|
||||
#include <QPalette>
|
||||
#include <QVector>
|
||||
#include <optional>
|
||||
|
||||
class CpuUsage {
|
||||
public:
|
||||
std::optional<double> sample(const QString &procStat);
|
||||
void reset() { m_previous.reset(); }
|
||||
private:
|
||||
struct Counters { quint64 total, idle; };
|
||||
std::optional<Counters> m_previous;
|
||||
};
|
||||
struct TrayStyle {
|
||||
bool border = true, transparent = false, fill = true, clamp = true, overflowColor = true;
|
||||
QColor borderColor, backgroundColor, fillColor, lineColor, outsideColor;
|
||||
double minimum = 0, maximum = 100;
|
||||
};
|
||||
QRectF trayPlotRect(bool border);
|
||||
QIcon telemetryIcon(bool graph, const QVector<QPointF> &values, const QString &unit, const TrayStyle &style);
|
||||
@@ -0,0 +1,145 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#include "traypage.h"
|
||||
#include "colorbutton.h"
|
||||
#include <QVBoxLayout>
|
||||
#include <QPushButton>
|
||||
#include <QColorDialog>
|
||||
#include <QLabel>
|
||||
#include <QSignalBlocker>
|
||||
|
||||
TrayPage::TrayPage(QSettings &settings, const QVector<Sensor> &sensors, QWidget *parent)
|
||||
: QWidget(parent), m_sensors(sensors)
|
||||
{
|
||||
for (const auto &key : settings.allKeys()) if (key.startsWith("tray/")) m_values[key] = settings.value(key);
|
||||
auto *layout = new QVBoxLayout(this);
|
||||
auto *form = new QFormLayout; form->setFieldGrowthPolicy(QFormLayout::FieldsStayAtSizeHint); layout->addLayout(form);
|
||||
m_mode = new QComboBox; m_mode->setObjectName("trayDisplayMode");
|
||||
m_mode->addItem("Application icon", "icon"); m_mode->addItem("History graph", "graph"); m_mode->addItem("Number", "number");
|
||||
m_metric = new QComboBox; m_metric->setObjectName("trayMetric");
|
||||
for (const auto &sensor : sensors) m_metric->addItem(sensor.name + " (" + sensor.unit + ")", sensor.id);
|
||||
m_mode->setCurrentIndex(std::max(0, m_mode->findData(m_values.value("tray/mode", "icon"))));
|
||||
m_metric->setCurrentIndex(std::max(0, m_metric->findData(m_values.value("tray/metric", "cpu-usage"))));
|
||||
form->addRow("Display:", m_mode); form->addRow("Reading:", m_metric);
|
||||
auto *appearance = new QWidget; auto *colors = new QFormLayout(appearance); colors->setContentsMargins(0, 0, 0, 0);
|
||||
colors->setFieldGrowthPolicy(QFormLayout::FieldsStayAtSizeHint);
|
||||
auto colorRow = [&](QFormLayout *target, const QString &label, const QString &key, const QColor &fallback, bool alpha = false) {
|
||||
auto *button = new ColorButton; button->setObjectName(key);
|
||||
auto refresh = [this, button, key, fallback] {
|
||||
const QColor color(m_values.value("tray/" + key, fallback.name(QColor::HexArgb)).toString());
|
||||
button->setColor(color);
|
||||
};
|
||||
m_values.insert("tray/" + key, m_values.value("tray/" + key, fallback.name(QColor::HexArgb)));
|
||||
m_reload.append(refresh);
|
||||
refresh(); target->addRow(label, button);
|
||||
connect(button, &QPushButton::clicked, this, [this, key, fallback, alpha, refresh] {
|
||||
const auto chosen = QColorDialog::getColor(QColor(m_values.value("tray/" + key, fallback.name(QColor::HexArgb)).toString()),
|
||||
this, "Choose colour", alpha ? QColorDialog::ShowAlphaChannel : QColorDialog::ColorDialogOptions{});
|
||||
if (!chosen.isValid()) return;
|
||||
m_values.insert("tray/" + key, chosen.name(QColor::HexArgb)); refresh(); if (!m_loading) Q_EMIT settingsChanged();
|
||||
});
|
||||
return button;
|
||||
};
|
||||
auto check = [&](QFormLayout *target, const QString &label, const QString &key, bool fallback) {
|
||||
auto *box = new QCheckBox(label); box->setObjectName(key); box->setChecked(m_values.value("tray/" + key, fallback).toBool());
|
||||
m_values.insert("tray/" + key, box->isChecked());
|
||||
m_reload.append([this, box, key, fallback] { box->setChecked(m_values.value("tray/" + key, fallback).toBool()); });
|
||||
target->addRow(box);
|
||||
connect(box, &QCheckBox::toggled, this, [this, key](bool value) { m_values.insert("tray/" + key, value); if (!m_loading) Q_EMIT settingsChanged(); });
|
||||
return box;
|
||||
};
|
||||
m_border = check(colors, "Show border", "border", true);
|
||||
auto *borderColor = colorRow(colors, "Border colour:", "borderColor", palette().color(QPalette::WindowText));
|
||||
borderColor->setEnabled(m_border->isChecked()); connect(m_border, &QCheckBox::toggled, borderColor, &QWidget::setEnabled);
|
||||
m_transparent = check(colors, "Transparent background", "transparent", false);
|
||||
auto *background = colorRow(colors, "Background colour:", "backgroundColor", palette().color(QPalette::Window));
|
||||
background->setDisabled(m_transparent->isChecked()); connect(m_transparent, &QCheckBox::toggled, background, &QWidget::setDisabled);
|
||||
colorRow(colors, "Line / number colour:", "lineColor", QColor("#3daee9"));
|
||||
layout->addWidget(appearance);
|
||||
m_graphSettings = new QWidget; m_scaleLayout = new QFormLayout(m_graphSettings); m_scaleLayout->setContentsMargins(0, 0, 0, 0);
|
||||
m_scaleLayout->setFieldGrowthPolicy(QFormLayout::FieldsStayAtSizeHint);
|
||||
m_minimum = new QDoubleSpinBox; m_minimum->setObjectName("trayMinimum");
|
||||
m_maximum = new QDoubleSpinBox; m_maximum->setObjectName("trayMaximum");
|
||||
m_scaleLayout->addRow("Graph minimum:", m_minimum); m_scaleLayout->addRow("Graph maximum:", m_maximum);
|
||||
m_fill = check(m_scaleLayout, "Fill below the line", "fill", true);
|
||||
auto *fillColor = colorRow(m_scaleLayout, "Fill colour:", "fillColor", QColor(61, 174, 233, 80), true);
|
||||
fillColor->setEnabled(m_fill->isChecked()); connect(m_fill, &QCheckBox::toggled, fillColor, &QWidget::setEnabled);
|
||||
m_outside = new QComboBox; m_outside->setObjectName("trayOutside");
|
||||
m_outside->addItem("Draw along the inner edge", "clamp"); m_outside->addItem("Do not draw outside readings", "hide");
|
||||
m_outside->setCurrentIndex(std::max(0, m_outside->findData(m_values.value("tray/outside", "clamp"))));
|
||||
m_scaleLayout->addRow("Outside graph range:", m_outside);
|
||||
m_overflowColor = check(m_scaleLayout, "Use a different colour outside the range", "overflowColor", true);
|
||||
auto *outsideColor = colorRow(m_scaleLayout, "Outside-range colour:", "outsideColor", QColor("#f67400"));
|
||||
auto syncOutside = [this, outsideColor] {
|
||||
m_overflowColor->setEnabled(m_outside->currentData() == "clamp");
|
||||
outsideColor->setEnabled(m_overflowColor->isEnabled() && m_overflowColor->isChecked());
|
||||
};
|
||||
connect(m_overflowColor, &QCheckBox::toggled, this, syncOutside);
|
||||
connect(m_outside, &QComboBox::currentIndexChanged, this, [this, syncOutside] {
|
||||
m_values.insert("tray/outside", m_outside->currentData()); syncOutside(); if (!m_loading) Q_EMIT settingsChanged();
|
||||
});
|
||||
syncOutside(); layout->addWidget(m_graphSettings);
|
||||
auto syncMode = [this, appearance] {
|
||||
const bool telemetry = mode() != "icon";
|
||||
m_metric->setEnabled(telemetry); appearance->setVisible(telemetry); m_graphSettings->setVisible(mode() == "graph");
|
||||
};
|
||||
connect(m_mode, &QComboBox::currentIndexChanged, this, [this, syncMode] {
|
||||
m_values.insert("tray/mode", m_mode->currentData()); syncMode(); if (!m_loading) Q_EMIT settingsChanged();
|
||||
});
|
||||
connect(m_metric, &QComboBox::currentIndexChanged, this, [this] {
|
||||
m_values.insert("tray/metric", m_metric->currentData()); loadScale(); if (!m_loading) Q_EMIT settingsChanged();
|
||||
});
|
||||
connect(m_minimum, &QDoubleSpinBox::valueChanged, this, [this](double value) {
|
||||
m_maximum->setMinimum(value + 1); m_values.insert(scaleKey("minimum"), value); if (!m_loading) Q_EMIT settingsChanged();
|
||||
});
|
||||
connect(m_maximum, &QDoubleSpinBox::valueChanged, this, [this](double value) {
|
||||
m_minimum->setMaximum(value - 1); m_values.insert(scaleKey("maximum"), value); if (!m_loading) Q_EMIT settingsChanged();
|
||||
});
|
||||
loadScale(); syncMode();
|
||||
m_values.insert("tray/mode", m_mode->currentData());
|
||||
m_values.insert("tray/metric", m_metric->currentData());
|
||||
m_values.insert("tray/outside", m_outside->currentData());
|
||||
m_reload.append([this, syncMode, syncOutside] {
|
||||
m_mode->setCurrentIndex(std::max(0, m_mode->findData(m_values.value("tray/mode", "icon"))));
|
||||
m_metric->setCurrentIndex(std::max(0, m_metric->findData(m_values.value("tray/metric", "cpu-usage"))));
|
||||
m_outside->setCurrentIndex(std::max(0, m_outside->findData(m_values.value("tray/outside", "clamp"))));
|
||||
syncMode(); syncOutside();
|
||||
});
|
||||
layout->addStretch();
|
||||
}
|
||||
void TrayPage::load(const QVariantMap &values)
|
||||
{
|
||||
m_loading = true; m_values = values;
|
||||
for (const auto &reload : m_reload) reload();
|
||||
m_values = values; loadScale(); m_loading = false;
|
||||
}
|
||||
QString TrayPage::scaleKey(const QString &suffix) const { return "tray/scales/" + metric().id + "/" + suffix; }
|
||||
void TrayPage::loadScale()
|
||||
{
|
||||
const QSignalBlocker a(m_minimum), b(m_maximum);
|
||||
const QString unit = metric().unit;
|
||||
const bool frequency = unit == "MHz", temperature = unit == "°C", rate = unit == "W";
|
||||
m_scaleLayout->setRowVisible(m_minimum, temperature || rate); m_scaleLayout->setRowVisible(m_maximum, temperature || frequency || rate);
|
||||
for (auto *spin : {m_minimum, m_maximum}) { spin->setDecimals(0); spin->setRange(rate ? -300 : temperature ? -50 : 0, frequency ? 20000 : 300); spin->setSuffix(" " + unit); }
|
||||
double maximum = frequency ? (metric().id == "cpu" ? cpuLimits().value("high", 5000).toDouble() : 3000) : rate ? 75 : 100;
|
||||
const double low = m_values.value(scaleKey("minimum"), rate ? -75 : 0).toDouble();
|
||||
maximum = m_values.value(scaleKey("maximum"), maximum).toDouble();
|
||||
m_minimum->setValue(low); m_maximum->setMinimum(m_minimum->value() + 1); m_maximum->setValue(maximum);
|
||||
m_minimum->setMaximum(m_maximum->value() - 1);
|
||||
}
|
||||
TrayStyle TrayPage::iconStyle() const
|
||||
{
|
||||
auto color = [this](const QString &key, const QColor &fallback) {
|
||||
const QColor value(m_values.value("tray/" + key, fallback.name(QColor::HexArgb)).toString());
|
||||
return value.isValid() ? value : fallback;
|
||||
};
|
||||
TrayStyle s;
|
||||
s.border = m_border->isChecked(); s.transparent = m_transparent->isChecked(); s.fill = m_fill->isChecked();
|
||||
s.clamp = m_outside->currentData() == "clamp"; s.overflowColor = m_overflowColor->isChecked();
|
||||
s.borderColor = color("borderColor", palette().color(QPalette::WindowText));
|
||||
s.backgroundColor = color("backgroundColor", palette().color(QPalette::Window));
|
||||
s.lineColor = color("lineColor", QColor("#3daee9")); s.fillColor = color("fillColor", QColor(61, 174, 233, 80));
|
||||
s.outsideColor = color("outsideColor", QColor("#f67400"));
|
||||
if (metric().unit == "MHz") s.maximum = m_maximum->value();
|
||||
else if (metric().unit == "°C" || metric().unit == "W") { s.minimum = m_minimum->value(); s.maximum = m_maximum->value(); }
|
||||
return s;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#pragma once
|
||||
#include "hardware.h"
|
||||
#include "tray.h"
|
||||
#include <QWidget>
|
||||
#include <QSettings>
|
||||
#include <QComboBox>
|
||||
#include <QDoubleSpinBox>
|
||||
#include <QCheckBox>
|
||||
#include <QFormLayout>
|
||||
#include <functional>
|
||||
|
||||
class TrayPage : public QWidget {
|
||||
Q_OBJECT
|
||||
public:
|
||||
TrayPage(QSettings &settings, const QVector<Sensor> &sensors, QWidget *parent = nullptr);
|
||||
QString mode() const { return m_mode->currentData().toString(); }
|
||||
Sensor metric() const { return m_sensors[m_metric->currentIndex()]; }
|
||||
TrayStyle iconStyle() const;
|
||||
QVariantMap draft() const { return m_values; }
|
||||
void load(const QVariantMap &values);
|
||||
Q_SIGNALS:
|
||||
void settingsChanged();
|
||||
private:
|
||||
void loadScale();
|
||||
QString scaleKey(const QString &suffix) const;
|
||||
QVariantMap m_values;
|
||||
QList<std::function<void()>> m_reload;
|
||||
bool m_loading = false;
|
||||
QVector<Sensor> m_sensors;
|
||||
QComboBox *m_mode, *m_metric, *m_outside;
|
||||
QDoubleSpinBox *m_minimum, *m_maximum;
|
||||
QFormLayout *m_scaleLayout;
|
||||
QWidget *m_graphSettings;
|
||||
QCheckBox *m_border, *m_transparent, *m_fill, *m_overflowColor;
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#include "valuecontrol.h"
|
||||
#include <QHBoxLayout>
|
||||
#include <QSignalBlocker>
|
||||
#include <algorithm>
|
||||
|
||||
ValueControl::ValueControl(QWidget *parent) : QWidget(parent),
|
||||
m_slider(new QSlider(Qt::Horizontal, this)), m_number(new QSpinBox(this))
|
||||
{
|
||||
auto *layout = new QHBoxLayout(this);
|
||||
layout->setContentsMargins(0, 0, 0, 0);
|
||||
layout->addWidget(m_slider, 1); layout->addWidget(m_number);
|
||||
m_slider->setMinimumWidth(100);
|
||||
setFocusProxy(m_number);
|
||||
connect(m_slider, &QSlider::valueChanged, m_number, &QSpinBox::setValue);
|
||||
connect(m_number, &QSpinBox::valueChanged, m_slider, &QSlider::setValue);
|
||||
connect(m_number, &QSpinBox::valueChanged, this, &ValueControl::valueChanged);
|
||||
setRange(0, 100);
|
||||
}
|
||||
void ValueControl::setRange(int minimum, int maximum)
|
||||
{
|
||||
// Block the slider while changing its range so its old value cannot clamp
|
||||
// the number field before both controls have the new bounds.
|
||||
const QSignalBlocker block(m_slider);
|
||||
m_slider->setRange(minimum, maximum);
|
||||
m_number->setRange(minimum, maximum);
|
||||
m_slider->setValue(m_number->value());
|
||||
m_slider->setPageStep(std::max(1, (maximum - minimum) / 10));
|
||||
updateRangeHint();
|
||||
}
|
||||
void ValueControl::setSuffix(const QString &suffix)
|
||||
{
|
||||
m_number->setSuffix(suffix); updateRangeHint();
|
||||
}
|
||||
void ValueControl::setSingleStep(int step)
|
||||
{
|
||||
m_slider->setSingleStep(step); m_number->setSingleStep(step);
|
||||
}
|
||||
void ValueControl::updateRangeHint()
|
||||
{
|
||||
m_slider->setToolTip(QString("%1–%2%3").arg(m_number->minimum()).arg(m_number->maximum()).arg(m_number->suffix()));
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#pragma once
|
||||
#include <QWidget>
|
||||
#include <QSlider>
|
||||
#include <QSpinBox>
|
||||
|
||||
// One bounded value, adjustable by dragging or by entering an exact number.
|
||||
class ValueControl : public QWidget {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit ValueControl(QWidget *parent = nullptr);
|
||||
int value() const { return m_number->value(); }
|
||||
int minimum() const { return m_number->minimum(); }
|
||||
int maximum() const { return m_number->maximum(); }
|
||||
void setValue(int value) { m_number->setValue(value); }
|
||||
void setRange(int minimum, int maximum);
|
||||
void setSuffix(const QString &suffix);
|
||||
void setSingleStep(int step);
|
||||
void setSpecialValueText(const QString &text) { m_number->setSpecialValueText(text); }
|
||||
Q_SIGNALS:
|
||||
void valueChanged(int value);
|
||||
private:
|
||||
void updateRangeHint();
|
||||
QSlider *m_slider;
|
||||
QSpinBox *m_number;
|
||||
};
|
||||
@@ -0,0 +1,548 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#include "window.h"
|
||||
#include "legend.h"
|
||||
#include "fan.h"
|
||||
#include <QMessageBox>
|
||||
#include <KAuth/Action>
|
||||
#include <KAuth/ExecuteJob>
|
||||
#include <KJob>
|
||||
#include <QApplication>
|
||||
#include <QCloseEvent>
|
||||
#include <QDBusInterface>
|
||||
#include <QDBusReply>
|
||||
#include <QFormLayout>
|
||||
#include <QGroupBox>
|
||||
#include <QMenu>
|
||||
#include <QPushButton>
|
||||
#include <QScrollArea>
|
||||
#include <QTabWidget>
|
||||
#include <QVBoxLayout>
|
||||
#include <QFile>
|
||||
#include <QSaveFile>
|
||||
#include <QStandardPaths>
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <cmath>
|
||||
|
||||
namespace {
|
||||
QLabel *note(const QString &text) { auto *label = new QLabel(text); label->setWordWrap(true); return label; }
|
||||
ValueControl *spin(int minimum, int maximum, const QString &suffix) {
|
||||
auto *box = new ValueControl; box->setRange(minimum, maximum); box->setSuffix(suffix); return box;
|
||||
}
|
||||
|
||||
QString duration(double seconds) {
|
||||
if (!std::isfinite(seconds) || seconds < 60) return "less than a minute";
|
||||
return QString("%1 h %2 min").arg(int(seconds) / 3600).arg(int(seconds) / 60 % 60);
|
||||
}
|
||||
}
|
||||
|
||||
Window::Window() : m_settings("fedora-tools", "framework-laptop-tools"),
|
||||
m_temperatures(temperatureSensors()), m_frequencies(frequencySensors())
|
||||
{
|
||||
for (const auto &entry : QList<QPair<QString, QList<int>>>{
|
||||
{"sampling/fast", {1000, 500, 2000, 4000}},
|
||||
{"sampling/battery", {30000, 15000, 60000, 120000}}}) {
|
||||
if (!entry.second.contains(m_settings.value(entry.first).toInt()))
|
||||
m_settings.setValue(entry.first, entry.second.first());
|
||||
}
|
||||
setWindowTitle("Framework Laptop Tools"); setWindowIcon(QIcon::fromTheme("computer-laptop")); resize(1000, 830);
|
||||
auto *central = new QWidget; auto *layout = new QVBoxLayout(central); setCentralWidget(central);
|
||||
m_message = note(compatibilityError()); layout->addWidget(m_message);
|
||||
auto *tabs = new QTabWidget; m_tabs = tabs; layout->addWidget(tabs);
|
||||
auto addPage = [&](QWidget *page, const QString &title) {
|
||||
auto *scroll = new QScrollArea; scroll->setWidgetResizable(true); scroll->setWidget(page); tabs->addTab(scroll, title);
|
||||
};
|
||||
addPage(monitorPage(), "Monitor");
|
||||
m_cpuPage = new CpuPage;
|
||||
m_controlPages = {lightingPage(), coolingPage(), batteryPage(), m_cpuPage};
|
||||
const QStringList titles{"Lighting", "Cooling", "Battery", "CPU"};
|
||||
for (int i = 0; i < m_controlPages.size(); ++i) {
|
||||
addPage(m_controlPages[i], titles[i]);
|
||||
m_controlPages[i]->setEnabled(compatibilityError().isEmpty());
|
||||
}
|
||||
addPage(trayPage(), "Tray icon");
|
||||
addPage(preferencesPage(), "Preferences");
|
||||
m_savedTray = m_trayPage->draft(); m_savedPreferences = preferenceValues();
|
||||
m_trayMetric = m_trayPage->metric(); m_trayMode = m_trayPage->mode(); m_trayStyle = m_trayPage->iconStyle();
|
||||
for (const auto &key : {"keyboard", "power", "chargeLimit", "chargeWatts", "fan"}) m_savedControls[key] = controlValue(key);
|
||||
m_pendingBar = new QWidget; m_pendingBar->setObjectName("pendingChanges");
|
||||
auto *bar = new QHBoxLayout(m_pendingBar);
|
||||
bar->addWidget(new QLabel("Unsaved changes")); bar->addStretch();
|
||||
m_saveButton = new QPushButton("Save and Apply"); m_saveButton->setObjectName("saveAll");
|
||||
m_undoButton = new QPushButton("Undo changes"); m_undoButton->setObjectName("undoAll");
|
||||
bar->addWidget(m_saveButton); bar->addWidget(m_undoButton); layout->addWidget(m_pendingBar);
|
||||
connect(m_saveButton, &QPushButton::clicked, this, &Window::saveChanges);
|
||||
connect(m_undoButton, &QPushButton::clicked, this, &Window::undoChanges);
|
||||
connect(m_cpuPage, &CpuPage::draftChanged, this, &Window::updatePendingBar);
|
||||
auto dirty = [this](const QString &key) {
|
||||
if (m_loadingControls) return;
|
||||
if (controlValue(key) == m_savedControls[key]) m_dirtyControls.remove(key); else m_dirtyControls.insert(key);
|
||||
updatePendingBar();
|
||||
};
|
||||
connect(m_keyboard, &ValueControl::valueChanged, this, [dirty] { dirty("keyboard"); });
|
||||
connect(m_power, &ValueControl::valueChanged, this, [dirty] { dirty("power"); });
|
||||
connect(m_powerAuto, &QCheckBox::toggled, this, [dirty] { dirty("power"); });
|
||||
connect(m_charge, &ValueControl::valueChanged, this, [dirty] { dirty("chargeLimit"); });
|
||||
connect(m_watts, &ValueControl::valueChanged, this, [dirty] { dirty("chargeWatts"); });
|
||||
connect(m_fanMode, &QComboBox::currentIndexChanged, this, [dirty] { dirty("fan"); });
|
||||
for (auto *control : m_curve + m_curveTemperatures + QVector<ValueControl *>{m_duty})
|
||||
connect(control, &ValueControl::valueChanged, this, [dirty] { dirty("fan"); });
|
||||
connect(tabs, &QTabWidget::currentChanged, this, [this](int index) {
|
||||
if (index >= 1 && index <= 4) refreshControls();
|
||||
});
|
||||
connect(&m_controlsTimer, &QTimer::timeout, this, [this] {
|
||||
if (isVisible() && !m_sleeping && m_tabs->currentIndex() >= 1 && m_tabs->currentIndex() <= 3) refreshControls();
|
||||
});
|
||||
m_controlsTimer.start(2000);
|
||||
m_tray = new QSystemTrayIcon(windowIcon(), this);
|
||||
auto *menu = new QMenu(this);
|
||||
menu->addAction("Open Framework Laptop Tools", this, [this] { show(); raise(); activateWindow(); });
|
||||
menu->addSeparator();
|
||||
menu->addAction("Quit", qApp, &QApplication::quit);
|
||||
m_tray->setContextMenu(menu);
|
||||
connect(m_tray, &QSystemTrayIcon::activated, this, [this](auto reason) {
|
||||
if (reason == QSystemTrayIcon::Trigger || reason == QSystemTrayIcon::DoubleClick) { show(); raise(); activateWindow(); }
|
||||
});
|
||||
m_tray->show();
|
||||
if (!QDBusConnection::systemBus().connect("org.freedesktop.login1", "/org/freedesktop/login1",
|
||||
"org.freedesktop.login1.Manager", "PrepareForSleep", this, SLOT(sleepChanged(bool))))
|
||||
m_message->setText("Sleep notifications unavailable; sleep intervals cannot be marked on the battery graph.");
|
||||
connect(&m_fastTimer, &QTimer::timeout, this, &Window::sample);
|
||||
connect(&m_batteryTimer, &QTimer::timeout, this, &Window::sampleBattery);
|
||||
m_fastTimer.start(m_settings.value("sampling/fast", 1000).toInt());
|
||||
m_batteryTimer.start(m_settings.value("sampling/battery", 30000).toInt());
|
||||
updatePendingBar(); sample(); sampleBattery(); refreshControls();
|
||||
}
|
||||
QGroupBox *Window::sensorGroup(const QString &title, Chart *chart, const QVector<Sensor> &sensors, bool temperatures)
|
||||
{
|
||||
auto *group = new QGroupBox; auto *layout = new QVBoxLayout(group);
|
||||
auto *heading = new QHBoxLayout;
|
||||
auto *label = new QLabel(title); label->setObjectName("chartTitle");
|
||||
auto font = label->font(); font.setPointSizeF(font.pointSizeF() + 2); label->setFont(font);
|
||||
heading->addWidget(label);
|
||||
if (title == "Fan speed" || title == "Battery") {
|
||||
auto *details = new QLabel; details->setWordWrap(true); heading->addWidget(details, 1);
|
||||
if (title == "Fan speed") m_fanDetails = details; else m_batteryDetails = details;
|
||||
} else heading->addStretch();
|
||||
layout->addLayout(heading);
|
||||
QVector<Sensor> main, extra;
|
||||
for (const auto &sensor : sensors) {
|
||||
chart->addSeries(sensor.id, sensor.name, sensor.unit);
|
||||
chart->setSelected(sensor.id, m_settings.value("series/" + sensor.id, !temperatures || sensor.primary).toBool());
|
||||
(temperatures && !sensor.primary ? extra : main).append(sensor);
|
||||
}
|
||||
auto makeLegend = [&](const QVector<Sensor> &entries) {
|
||||
auto *legend = new Legend(chart, entries);
|
||||
connect(legend, &Legend::selectionChanged, this, [this](const QString &id, bool selected) {
|
||||
m_settings.setValue("series/" + id, selected);
|
||||
});
|
||||
return legend;
|
||||
};
|
||||
auto *mainLegend = makeLegend(main); layout->addWidget(mainLegend);
|
||||
if (!extra.isEmpty()) {
|
||||
auto *legend = makeLegend(extra); legend->hide();
|
||||
auto updateToggle = [mainLegend, legend] {
|
||||
mainLegend->setExtraLink(QString("%1 More sensors (%2 enabled)")
|
||||
.arg(legend->isHidden() ? "▸" : "▾").arg(legend->enabledCount()));
|
||||
};
|
||||
connect(mainLegend, &Legend::extraActivated, this, [legend, updateToggle] {
|
||||
legend->setVisible(legend->isHidden()); updateToggle();
|
||||
});
|
||||
connect(legend, &Legend::selectionChanged, this, updateToggle);
|
||||
updateToggle(); layout->addWidget(legend);
|
||||
}
|
||||
layout->addWidget(chart);
|
||||
return group;
|
||||
}
|
||||
QWidget *Window::monitorPage()
|
||||
{
|
||||
auto *page = new QWidget; auto *layout = new QVBoxLayout(page);
|
||||
m_frequencyChart = new Chart("MHz"); m_temperatureChart = new Chart("°C"); m_batteryChart = new Chart("%");
|
||||
layout->addWidget(sensorGroup("CPU and GPU frequency", m_frequencyChart, m_frequencies, false));
|
||||
m_fanChart = new Chart("RPM");
|
||||
auto *fanGroup = sensorGroup("Fan speed", m_fanChart, {{"fan", "Fan speed", {}, "RPM"}}, false);
|
||||
layout->addWidget(fanGroup);
|
||||
layout->addWidget(sensorGroup("Temperatures", m_temperatureChart, m_temperatures, true));
|
||||
auto *batteryGroup = sensorGroup("Battery", m_batteryChart,
|
||||
{{"battery", "Charge level", {}, "%"}, {"battery-rate", "Charge / discharge rate", {}, "W"}}, false);
|
||||
layout->addWidget(batteryGroup);
|
||||
const QList<Chart *> charts{m_frequencyChart, m_fanChart, m_temperatureChart, m_batteryChart};
|
||||
for (auto *source : charts) connect(source, &Chart::hovered, this, [charts](qint64 time) {
|
||||
for (auto *chart : charts) chart->setHoverTime(time);
|
||||
});
|
||||
return page;
|
||||
}
|
||||
QWidget *Window::lightingPage()
|
||||
{
|
||||
auto *page = new QWidget; auto *layout = new QVBoxLayout(page);
|
||||
auto *keyboard = new QGroupBox("Keyboard"); auto *keys = new QFormLayout(keyboard);
|
||||
m_keyboard = spin(0, 100, "%"); m_keyboard->setObjectName("keyboardBrightness");
|
||||
keys->addRow("Keyboard brightness:", m_keyboard);
|
||||
keys->addRow(note("The current firmware interface does not expose setting or detecting automatic brightness. If Auto is set using Fn+Space, any manual brightness here will be overridden."));
|
||||
layout->addWidget(keyboard);
|
||||
auto *power = new QGroupBox("Power button"); auto *lights = new QFormLayout(power);
|
||||
m_power = spin(1, 100, "%"); m_power->setObjectName("powerBrightness");
|
||||
m_powerAuto = new QCheckBox("Automatic brightness"); m_powerAuto->setObjectName("powerAuto");
|
||||
lights->addRow(m_powerAuto); lights->addRow("Power-button brightness:", m_power);
|
||||
connect(m_powerAuto, &QCheckBox::toggled, m_power, &QWidget::setDisabled);
|
||||
m_firmwareReadout = note(""); lights->addRow(m_firmwareReadout);
|
||||
layout->addWidget(power); layout->addStretch(); return page;
|
||||
}
|
||||
QWidget *Window::coolingPage()
|
||||
{
|
||||
auto *page = new QWidget; auto *layout = new QVBoxLayout(page);
|
||||
auto *fans = new QGroupBox; auto *form = new QFormLayout(fans);
|
||||
m_fanReadout = note(""); form->addRow(m_fanReadout);
|
||||
m_fanMode = new QComboBox; m_fanMode->setObjectName("fanMode");
|
||||
m_fanMode->addItems({"Firmware Auto", "Manual", "Curve"}); form->addRow("Fan mode:", m_fanMode);
|
||||
m_duty = spin(0, 100, "%"); m_duty->setObjectName("fanDuty"); m_duty->setValue(50);
|
||||
form->addRow("Manual speed:", m_duty);
|
||||
m_fanWarning = note("Warning: below 30% the fan may stop or provide insufficient cooling.");
|
||||
m_fanWarning->setObjectName("lowFanWarning"); form->addRow(m_fanWarning);
|
||||
const QList<int> defaults{30, 40, 70, 100};
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
auto *point = spin(0, 100, "%"); point->setValue(defaults[i]); m_curve.append(point);
|
||||
auto *temperature = spin(20, 85, " °C"); temperature->setValue(40 + 15 * i); m_curveTemperatures.append(temperature);
|
||||
auto *row = new QWidget; auto *line = new QHBoxLayout(row); line->setContentsMargins(0, 0, 0, 0);
|
||||
line->addWidget(temperature); line->addWidget(point); form->addRow(QString("Curve point %1:").arg(i + 1), row);
|
||||
}
|
||||
auto modeChanged = [this] {
|
||||
const int index = m_fanMode->currentIndex();
|
||||
m_duty->setEnabled(index == 1);
|
||||
m_fanWarning->setVisible(index == 1 && m_duty->value() < 30);
|
||||
for (int i = 0; i < m_curve.size(); ++i) m_curve[i]->setEnabled(index == 2 && i != 3);
|
||||
for (auto *point : m_curveTemperatures) point->setEnabled(index == 2);
|
||||
};
|
||||
connect(m_fanMode, &QComboBox::currentIndexChanged, this, modeChanged);
|
||||
connect(m_duty, &ValueControl::valueChanged, this, modeChanged); modeChanged();
|
||||
form->addRow(note("Uses the hottest EC sensor. The final curve point must reach 100% by 85 °C."));
|
||||
fans->setEnabled(!ecHwmon().isEmpty()); layout->addWidget(fans); layout->addStretch(); return page;
|
||||
}
|
||||
QWidget *Window::batteryPage()
|
||||
{
|
||||
auto *page = new QWidget; auto *layout = new QVBoxLayout(page);
|
||||
auto *group = new QGroupBox("Battery"); auto *form = new QFormLayout(group);
|
||||
m_charge = spin(50, 100, "%"); m_charge->setValue(100); m_charge->setObjectName("chargeLimit");
|
||||
form->addRow("Limit battery:", m_charge);
|
||||
m_watts = spin(0, 75, " W"); m_watts->setSpecialValueText("Firmware default"); m_watts->setObjectName("chargeWatts");
|
||||
m_watts->setToolTip("Zero restores firmware defaults. The current charging-power limit cannot be read back.");
|
||||
form->addRow("Limit charging power:", m_watts);
|
||||
form->addRow(note("Limiting charging power is approximate."));
|
||||
layout->addWidget(group); layout->addStretch(); return page;
|
||||
}
|
||||
QWidget *Window::preferencesPage()
|
||||
{
|
||||
auto *page = new QWidget; auto *layout = new QFormLayout(page);
|
||||
m_fastChoice = new QComboBox; m_fastChoice->setObjectName("fastInterval");
|
||||
m_batteryChoice = new QComboBox; m_batteryChoice->setObjectName("batteryInterval");
|
||||
for (int ms : {500, 1000, 2000, 4000}) m_fastChoice->addItem(ms == 1000 ? "1 second" : QString("%1 seconds").arg(ms / 1000.), ms);
|
||||
for (int ms : {15000, 30000, 60000, 120000}) m_batteryChoice->addItem(ms < 60000 ? QString("%1 seconds").arg(ms / 1000) : ms == 60000 ? "1 minute" : "2 minutes", ms);
|
||||
m_fastChoice->setCurrentIndex(std::max(0, m_fastChoice->findData(m_settings.value("sampling/fast", 1000))));
|
||||
m_batteryChoice->setCurrentIndex(std::max(0, m_batteryChoice->findData(m_settings.value("sampling/battery", 30000))));
|
||||
layout->addRow("Frequency / temperature updates:", m_fastChoice); layout->addRow("Battery graph updates:", m_batteryChoice);
|
||||
m_autostart = new QCheckBox("Start in the tray when I log in"); m_autostart->setObjectName("loginAutostart");
|
||||
m_autostartPath = QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation) + "/autostart/se.ajpanton.framework-laptop-tools.desktop";
|
||||
m_autostart->setChecked(QFile::exists(m_autostartPath)); layout->addRow(m_autostart);
|
||||
connect(m_fastChoice, &QComboBox::currentIndexChanged, this, &Window::updatePendingBar);
|
||||
connect(m_batteryChoice, &QComboBox::currentIndexChanged, this, &Window::updatePendingBar);
|
||||
connect(m_autostart, &QCheckBox::toggled, this, &Window::updatePendingBar);
|
||||
return page;
|
||||
}
|
||||
QWidget *Window::trayPage()
|
||||
{
|
||||
QVector<Sensor> sensors{{"cpu-usage", "CPU usage", {}, "%"}};
|
||||
sensors += m_frequencies; sensors += m_temperatures;
|
||||
sensors += QVector<Sensor>{{"battery", "Battery charge", {}, "%"}, {"battery-rate", "Battery rate", {}, "W"}};
|
||||
m_trayPage = new TrayPage(m_settings, sensors);
|
||||
connect(m_trayPage, &TrayPage::settingsChanged, this, &Window::updatePendingBar);
|
||||
return m_trayPage;
|
||||
}
|
||||
void Window::updateTray()
|
||||
{
|
||||
const auto metric = m_trayMetric;
|
||||
QVector<QPointF> history;
|
||||
if (metric.id == "cpu-usage") history = m_usageHistory;
|
||||
else if (metric.id == "battery" || metric.id == "battery-rate") history = m_batteryChart->history(metric.id);
|
||||
else history = (metric.unit == "MHz" ? m_frequencyChart : m_temperatureChart)->history(metric.id);
|
||||
if (m_trayMode == "icon") m_tray->setIcon(windowIcon());
|
||||
else m_tray->setIcon(telemetryIcon(m_trayMode == "graph", history, metric.unit, m_trayStyle));
|
||||
const auto value = m_values.constFind(metric.id);
|
||||
m_tray->setToolTip(metric.name + ": " + (value == m_values.cend() ? "—" : QString::number(*value, 'f', 1) + " " + metric.unit)
|
||||
+ "\n" + m_batteryText + "\n" + m_fanReadout->text());
|
||||
}
|
||||
QVariantMap Window::controlValue(const QString &key) const
|
||||
{
|
||||
if (key == "power") return m_powerAuto->isChecked() ? QVariantMap{{"operation", "powerAuto"}, {"value", m_power->value()}}
|
||||
: QVariantMap{{"operation", "powerBrightness"}, {"value", m_power->value()}};
|
||||
if (key == "fan") {
|
||||
QVariantList curve, temperatures;
|
||||
for (auto *point : m_curve) curve.append(point->value());
|
||||
for (auto *point : m_curveTemperatures) temperatures.append(point->value());
|
||||
return {{"operation", "fan"}, {"mode", QStringList{"auto", "manual", "curve"}[m_fanMode->currentIndex()]},
|
||||
{"duty", m_duty->value()}, {"curve", curve}, {"temperatures", temperatures}};
|
||||
}
|
||||
auto *control = key == "keyboard" ? m_keyboard : key == "chargeLimit" ? m_charge : m_watts;
|
||||
return {{"operation", key}, {"value", control->value()}};
|
||||
}
|
||||
void Window::loadControl(const QString &key, const QVariantMap &value)
|
||||
{
|
||||
if (key == "power") {
|
||||
m_powerAuto->setChecked(value["operation"] == "powerAuto");
|
||||
if (value.contains("value")) m_power->setValue(value["value"].toInt());
|
||||
} else if (key == "fan") {
|
||||
m_fanMode->setCurrentIndex(QStringList{"auto", "manual", "curve"}.indexOf(value["mode"].toString()));
|
||||
m_duty->setValue(value["duty"].toInt());
|
||||
for (int i = 0; i < m_curve.size(); ++i) {
|
||||
m_curve[i]->setValue(value["curve"].toList()[i].toInt());
|
||||
m_curveTemperatures[i]->setValue(value["temperatures"].toList()[i].toInt());
|
||||
}
|
||||
} else {
|
||||
auto *control = key == "keyboard" ? m_keyboard : key == "chargeLimit" ? m_charge : m_watts;
|
||||
control->setValue(value["value"].toInt());
|
||||
}
|
||||
}
|
||||
QVariantMap Window::preferenceValues() const
|
||||
{
|
||||
return {{"sampling/fast", m_fastChoice->currentData()}, {"sampling/battery", m_batteryChoice->currentData()},
|
||||
{"autostart", m_autostart->isChecked()}};
|
||||
}
|
||||
void Window::updatePendingBar()
|
||||
{
|
||||
if (!m_pendingBar || m_loadingControls) return;
|
||||
const bool dirty = !m_dirtyControls.isEmpty() || m_cpuPage->dirty() ||
|
||||
m_trayPage->draft() != m_savedTray || preferenceValues() != m_savedPreferences;
|
||||
m_pendingBar->setVisible(dirty || m_saving);
|
||||
m_saveButton->setEnabled(!m_busy && !m_saving); m_undoButton->setEnabled(!m_busy && !m_saving);
|
||||
m_tabs->setEnabled(!m_saving);
|
||||
}
|
||||
void Window::undoChanges()
|
||||
{
|
||||
if (m_busy || m_saving) return;
|
||||
m_loadingControls = true;
|
||||
for (auto it = m_savedControls.cbegin(); it != m_savedControls.cend(); ++it) loadControl(it.key(), it.value());
|
||||
m_dirtyControls.clear(); m_cpuPage->undo(); m_trayPage->load(m_savedTray);
|
||||
m_fastChoice->setCurrentIndex(m_fastChoice->findData(m_savedPreferences["sampling/fast"]));
|
||||
m_batteryChoice->setCurrentIndex(m_batteryChoice->findData(m_savedPreferences["sampling/battery"]));
|
||||
m_autostart->setChecked(m_savedPreferences["autostart"].toBool());
|
||||
m_loadingControls = false; m_message->clear(); updatePendingBar(); refreshControls();
|
||||
}
|
||||
QStringList Window::dangerousChanges() const
|
||||
{
|
||||
QStringList warnings;
|
||||
if (m_dirtyControls.contains("fan")) {
|
||||
if (m_fanMode->currentIndex() == 1 && m_duty->value() < 30)
|
||||
warnings << QString("Manual fan speed: %1%. The fan may stop or provide insufficient cooling.").arg(m_duty->value());
|
||||
if (m_fanMode->currentIndex() == 2) {
|
||||
for (auto *point : m_curve) if (point->value() < 30) {
|
||||
warnings << "Fan curve includes speeds below 30%. The fan may stop at those temperatures."; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
void Window::saveChanges()
|
||||
{
|
||||
if (m_busy || m_saving) return;
|
||||
QString error;
|
||||
if (m_dirtyControls.contains("fan")) error = validateFan(controlValue("fan"));
|
||||
if (error.isEmpty() && m_cpuPage->dirty()) error = validateCpuConfig(m_cpuPage->draft());
|
||||
if (!error.isEmpty()) { m_message->setText(error); return; }
|
||||
const auto warnings = dangerousChanges();
|
||||
if (!warnings.isEmpty()) {
|
||||
QMessageBox confirmation(QMessageBox::Warning, "Apply potentially dangerous settings?",
|
||||
warnings.join("\n\n") + "\n\nThese settings will also be restored after reboot and sleep. Apply them?",
|
||||
QMessageBox::Yes | QMessageBox::Cancel, this);
|
||||
confirmation.setDefaultButton(QMessageBox::Cancel);
|
||||
if (confirmation.exec() != QMessageBox::Yes) return;
|
||||
}
|
||||
QVariantList operations;
|
||||
for (const auto &key : {"keyboard", "power", "chargeLimit", "chargeWatts", "fan"})
|
||||
if (m_dirtyControls.contains(key)) operations.append(controlValue(key));
|
||||
if (m_cpuPage->dirty()) operations.append(QVariantMap{{"operation", "cpuProfiles"}, {"config", m_cpuPage->draft()}});
|
||||
m_saving = true; updatePendingBar();
|
||||
if (operations.isEmpty()) finishSave(); else request({{"operation", "batch"}, {"operations", operations}});
|
||||
}
|
||||
QString Window::saveLocalSettings()
|
||||
{
|
||||
const auto preferences = preferenceValues();
|
||||
if (preferences["autostart"] != m_savedPreferences["autostart"]) {
|
||||
if (m_autostart->isChecked()) {
|
||||
if (!QDir().mkpath(QFileInfo(m_autostartPath).absolutePath())) return "Could not create the autostart directory.";
|
||||
QSaveFile file(m_autostartPath);
|
||||
const QByteArray data("[Desktop Entry]\nType=Application\nName=Framework Laptop Tools\nExec=framework-laptop-tools --tray\nIcon=computer-laptop\n");
|
||||
if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size() || !file.commit())
|
||||
return "Could not save login autostart: " + file.errorString();
|
||||
} else if (QFile::exists(m_autostartPath) && !QFile::remove(m_autostartPath)) return "Could not remove login autostart.";
|
||||
}
|
||||
const auto tray = m_trayPage->draft();
|
||||
for (const auto &key : m_settings.allKeys()) if (key.startsWith("tray/")) m_settings.remove(key);
|
||||
for (auto it = tray.cbegin(); it != tray.cend(); ++it) m_settings.setValue(it.key(), it.value());
|
||||
for (const auto &key : {"sampling/fast", "sampling/battery"}) m_settings.setValue(key, preferences[key]);
|
||||
m_settings.sync();
|
||||
if (m_settings.status() != QSettings::NoError) return "Could not save application settings.";
|
||||
m_savedTray = tray; m_savedPreferences = preferences;
|
||||
m_fastTimer.start(preferences["sampling/fast"].toInt()); m_batteryTimer.start(preferences["sampling/battery"].toInt());
|
||||
m_trayMetric = m_trayPage->metric(); m_trayMode = m_trayPage->mode(); m_trayStyle = m_trayPage->iconStyle(); updateTray();
|
||||
return {};
|
||||
}
|
||||
void Window::finishSave()
|
||||
{
|
||||
const auto error = saveLocalSettings();
|
||||
m_saving = false;
|
||||
m_message->setText(error.isEmpty() ? "Settings saved and applied." : "Save stopped: " + error + " Earlier changes may already have applied.");
|
||||
updatePendingBar(); refreshControls();
|
||||
}
|
||||
void Window::refreshControls()
|
||||
{
|
||||
if (m_busy || m_saving) return;
|
||||
m_loadingControls = true;
|
||||
const auto keyboard = readNumber("/sys/class/leds/chromeos::kbd_backlight/brightness");
|
||||
m_keyboard->setEnabled(keyboard.has_value());
|
||||
if (keyboard && !m_dirtyControls.contains("keyboard")) {
|
||||
m_keyboard->setValue(int(*keyboard)); m_savedControls["keyboard"] = controlValue("keyboard");
|
||||
}
|
||||
m_loadingControls = false;
|
||||
if (compatibilityError().isEmpty()) request({}, true);
|
||||
}
|
||||
void Window::request(const QVariantMap &arguments, bool inspect)
|
||||
{
|
||||
if (m_busy) return;
|
||||
if (!compatibilityError().isEmpty()) {
|
||||
m_saving = false; m_message->setText(compatibilityError()); updatePendingBar(); return;
|
||||
}
|
||||
m_busy = true; updatePendingBar();
|
||||
if (!inspect) m_message->setText("Applying settings…");
|
||||
KAuth::Action action(QString("se.ajpanton.frameworktools.") + (inspect ? "inspect" : "configure"));
|
||||
action.setHelperId("se.ajpanton.frameworktools"); action.setArguments(arguments); action.setTimeout(inspect ? 60000 : 180000);
|
||||
auto *job = action.execute();
|
||||
connect(job, &KJob::result, this, [this, job, inspect](KJob *) {
|
||||
m_busy = false;
|
||||
if (job->error()) {
|
||||
m_message->setText(inspect ? job->errorString() : "Save stopped: " + job->errorString() + " Earlier changes may already have applied; remaining edits are still unsaved.");
|
||||
m_saving = false; updatePendingBar(); return;
|
||||
}
|
||||
if (inspect) {
|
||||
m_loadingControls = true;
|
||||
const auto data = job->data();
|
||||
if (!data.contains("cpuError")) m_cpuPage->load(data["cpuConfig"].toMap());
|
||||
if (data.contains("chargeLimit")) {
|
||||
m_chargeLimit = data["chargeLimit"].toInt();
|
||||
if (!m_dirtyControls.contains("chargeLimit")) m_charge->setValue(m_chargeLimit);
|
||||
}
|
||||
m_chargeOverride = data.value("chargeOverride").toBool();
|
||||
if (data.contains("fanMode") && !m_dirtyControls.contains("fan")) {
|
||||
m_fanMode->setCurrentIndex(QStringList{"auto", "manual", "curve"}.indexOf(data["fanMode"].toString()));
|
||||
if (data.contains("fanDuty")) m_duty->setValue(data["fanDuty"].toInt());
|
||||
const auto curve = data["fanCurve"].toList(), temperatures = data["fanTemperatures"].toList();
|
||||
if (curve.size() == m_curve.size()) for (int i = 0; i < curve.size(); ++i) m_curve[i]->setValue(curve[i].toInt());
|
||||
if (temperatures.size() == m_curveTemperatures.size()) for (int i = 0; i < temperatures.size(); ++i) m_curveTemperatures[i]->setValue(temperatures[i].toInt());
|
||||
}
|
||||
if (!m_dirtyControls.contains("power")) {
|
||||
if (data.contains("powerBrightness")) m_power->setValue(data["powerBrightness"].toInt());
|
||||
if (data.contains("powerAuto")) m_powerAuto->setChecked(data["powerAuto"].toBool());
|
||||
}
|
||||
for (const auto &key : {"power", "chargeLimit", "fan"}) if (!m_dirtyControls.contains(key)) m_savedControls[key] = controlValue(key);
|
||||
m_firmwareReadout->setText(data.contains("powerBrightness") ? QString("Current: %1% (%2)").arg(data["powerBrightness"].toInt()).arg(data["powerAuto"].toBool() ? "Auto" : "fixed") : data["powerError"].toString());
|
||||
for (const auto &key : {"cpuError", "chargeError", "fanError"}) if (data.contains(key)) m_message->setText(data[key].toString());
|
||||
m_loadingControls = false; updatePendingBar();
|
||||
} else {
|
||||
const auto data = job->data();
|
||||
for (const auto &entry : data.value("completed").toList()) {
|
||||
const auto value = entry.toMap(); const auto operation = value["operation"].toString();
|
||||
if (operation == "cpuProfiles") m_cpuPage->load(value["config"].toMap(), true);
|
||||
else {
|
||||
const auto key = operation.startsWith("power") ? QString("power") : operation;
|
||||
m_savedControls[key] = value; m_dirtyControls.remove(key);
|
||||
}
|
||||
}
|
||||
if (data.contains("applyError")) {
|
||||
m_saving = false;
|
||||
m_message->setText("Save stopped: " + data["applyError"].toString() + " Earlier changes may already have applied; remaining edits are still unsaved.");
|
||||
updatePendingBar(); refreshControls();
|
||||
} else finishSave();
|
||||
}
|
||||
});
|
||||
job->start();
|
||||
}
|
||||
void Window::sample()
|
||||
{
|
||||
if (m_sleeping) return;
|
||||
m_values.clear();
|
||||
m_cpuPage->refreshStatus();
|
||||
auto sampleSensors = [&](const QVector<Sensor> &sensors, Chart *chart) {
|
||||
QMap<QString, double> values;
|
||||
for (const auto &sensor : sensors) {
|
||||
const auto n = sensorValue(sensor);
|
||||
if (n) { values.insert(sensor.id, *n); m_values.insert(sensor.id, *n); }
|
||||
}
|
||||
chart->sample(values, m_fastTimer.interval());
|
||||
};
|
||||
sampleSensors(m_frequencies, m_frequencyChart); sampleSensors(m_temperatures, m_temperatureChart);
|
||||
const QString ec = ecHwmon();
|
||||
const auto rpm = readNumber(ec + "/fan1_input");
|
||||
const auto mode = readNumber(ec + "/pwm1_enable");
|
||||
const auto pwm = readNumber(ec + "/pwm1");
|
||||
const QString fanMode = !mode ? "Mode unavailable" : *mode == 2 ? "Firmware Auto" : "Manual / curve override";
|
||||
m_fanDetails->setText("· " + fanMode);
|
||||
m_fanReadout->setText((rpm ? QString("%1 RPM").arg(*rpm, 0, 'f', 0) : "Fan speed unavailable")
|
||||
+ (pwm ? QString(" · Duty: %1%").arg(*pwm * 100 / 255, 0, 'f', 0) : " · Duty unavailable") + " · " + fanMode);
|
||||
QMap<QString, double> fans;
|
||||
if (rpm) fans["fan"] = *rpm;
|
||||
m_fanChart->sample(fans, m_fastTimer.interval());
|
||||
const auto battery = batteryStatus();
|
||||
const auto usage = m_cpuUsage.sample(readText("/proc/stat"));
|
||||
if (usage) m_values["cpu-usage"] = *usage;
|
||||
m_usageHistory.append({double(QDateTime::currentMSecsSinceEpoch()), usage.value_or(NAN)});
|
||||
if (m_usageHistory.size() > 600) m_usageHistory.removeFirst();
|
||||
if (battery.contains("capacity")) m_values["battery"] = battery["capacity"].toDouble();
|
||||
const auto rate = batteryRate(battery); if (rate) m_values["battery-rate"] = *rate;
|
||||
m_batteryChart->setPowerState(false, onAcPower());
|
||||
QString text = battery.isEmpty() ? "Battery unavailable" : QString("Battery %1% · %2").arg(battery.value("capacity").toInt()).arg(battery.value("state").toString());
|
||||
if (battery.contains("watts")) text += QString(" · %1 W").arg(battery["watts"].toDouble(), 0, 'f', 1);
|
||||
if (battery.contains("health")) text += QString(" · Capacity / design %1%").arg(battery["health"].toDouble(), 0, 'f', 0);
|
||||
if (battery.contains("cycle_count")) text += QString(" · %1 cycles").arg(battery["cycle_count"].toInt());
|
||||
QStringList details;
|
||||
if (battery.contains("fullMWh")) details << QString("Capacity: %1 mWh").arg(battery["fullMWh"].toDouble(), 0, 'f', 0);
|
||||
if (battery.contains("health")) details << QString("Health: %1%").arg(battery["health"].toDouble(), 0, 'f', 0);
|
||||
if (battery.contains("cycle_count")) details << QString("%1 cycles").arg(battery["cycle_count"].toInt());
|
||||
m_batteryDetails->setText("· " + details.join(" · "));
|
||||
const int effectiveLimit = m_chargeOverride ? 100 : m_chargeLimit;
|
||||
QDBusInterface properties("org.freedesktop.UPower", "/org/freedesktop/UPower/devices/DisplayDevice", "org.freedesktop.DBus.Properties", QDBusConnection::systemBus());
|
||||
properties.setTimeout(250);
|
||||
const QDBusReply<QVariantMap> response = properties.call("GetAll", "org.freedesktop.UPower.Device");
|
||||
if (response.isValid()) {
|
||||
if (battery["state"] == "Discharging" && response.value()["TimeToEmpty"].toLongLong() > 0)
|
||||
text += " · " + duration(response.value()["TimeToEmpty"].toDouble()) + " remaining (OS estimate)";
|
||||
else if (battery["state"] == "Charging" && response.value()["TimeToFull"].toLongLong() > 0 && effectiveLimit == 100)
|
||||
text += " · " + duration(response.value()["TimeToFull"].toDouble()) + " until full (OS estimate)";
|
||||
}
|
||||
if (battery["state"] == "Charging" && effectiveLimit < 100 && battery["current_now"].toDouble() > 0
|
||||
&& battery.contains("charge_now") && battery["charge_full"].toDouble() > 0) {
|
||||
const double remaining = battery["charge_full"].toDouble() * effectiveLimit / 100 - battery["charge_now"].toDouble();
|
||||
text += remaining > 0 ? " · about " + duration(remaining / battery["current_now"].toDouble() * 3600) + QString(" to %1% (at current rate)").arg(effectiveLimit) : " · charge limit reached";
|
||||
}
|
||||
m_batteryText = text;
|
||||
updateTray();
|
||||
}
|
||||
void Window::sampleBattery()
|
||||
{
|
||||
if (m_sleeping) return;
|
||||
const auto battery = batteryStatus(); QMap<QString, double> values;
|
||||
if (battery.contains("capacity")) values["battery"] = battery["capacity"].toDouble();
|
||||
const auto rate = batteryRate(battery); if (rate) values["battery-rate"] = *rate;
|
||||
m_batteryChart->sample(values, m_batteryTimer.interval());
|
||||
updateTray();
|
||||
}
|
||||
void Window::sleepChanged(bool sleeping)
|
||||
{
|
||||
m_sleeping = sleeping;
|
||||
for (auto *chart : {m_frequencyChart, m_temperatureChart, m_batteryChart, m_fanChart})
|
||||
chart->setPowerState(sleeping, sleeping || chart != m_batteryChart ? std::nullopt : onAcPower());
|
||||
m_cpuUsage.reset();
|
||||
if (!sleeping) { sample(); sampleBattery(); }
|
||||
}
|
||||
void Window::closeEvent(QCloseEvent *event)
|
||||
{
|
||||
if (QSystemTrayIcon::isSystemTrayAvailable()) { hide(); event->ignore(); }
|
||||
else event->accept();
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#pragma once
|
||||
#include "chart.h"
|
||||
#include "cpupage.h"
|
||||
#include "tray.h"
|
||||
#include "traypage.h"
|
||||
#include <QMainWindow>
|
||||
#include <QSettings>
|
||||
#include <QTimer>
|
||||
#include <QLabel>
|
||||
#include <QSystemTrayIcon>
|
||||
#include <QCheckBox>
|
||||
#include <QComboBox>
|
||||
class QGroupBox;
|
||||
class QTabWidget;
|
||||
class QPushButton;
|
||||
|
||||
class Window : public QMainWindow {
|
||||
Q_OBJECT
|
||||
public:
|
||||
Window();
|
||||
protected:
|
||||
void closeEvent(QCloseEvent *event) override;
|
||||
private:
|
||||
QWidget *monitorPage();
|
||||
QWidget *lightingPage();
|
||||
QWidget *coolingPage();
|
||||
QWidget *batteryPage();
|
||||
QWidget *preferencesPage();
|
||||
QWidget *trayPage();
|
||||
void updateTray();
|
||||
void sample();
|
||||
void sampleBattery();
|
||||
void request(const QVariantMap &arguments, bool inspect = false);
|
||||
void refreshControls();
|
||||
QVariantMap controlValue(const QString &key) const;
|
||||
void loadControl(const QString &key, const QVariantMap &value);
|
||||
QVariantMap preferenceValues() const;
|
||||
void updatePendingBar();
|
||||
void saveChanges();
|
||||
void undoChanges();
|
||||
void finishSave();
|
||||
QString saveLocalSettings();
|
||||
QStringList dangerousChanges() const;
|
||||
QGroupBox *sensorGroup(const QString &title, Chart *chart, const QVector<Sensor> &sensors, bool temperatures);
|
||||
QSettings m_settings;
|
||||
QTimer m_fastTimer, m_batteryTimer, m_controlsTimer;
|
||||
QTabWidget *m_tabs;
|
||||
QVector<Sensor> m_temperatures, m_frequencies;
|
||||
QMap<QString, double> m_values;
|
||||
CpuUsage m_cpuUsage;
|
||||
QVector<QPointF> m_usageHistory;
|
||||
Chart *m_frequencyChart, *m_temperatureChart, *m_batteryChart, *m_fanChart;
|
||||
QLabel *m_batteryDetails, *m_fanDetails;
|
||||
QLabel *m_message, *m_fanReadout, *m_firmwareReadout;
|
||||
QString m_batteryText;
|
||||
QVector<QWidget *> m_controlPages;
|
||||
CpuPage *m_cpuPage;
|
||||
QSystemTrayIcon *m_tray;
|
||||
ValueControl *m_keyboard, *m_power, *m_charge, *m_watts, *m_duty;
|
||||
QCheckBox *m_powerAuto;
|
||||
QLabel *m_fanWarning;
|
||||
QVector<ValueControl *> m_curve;
|
||||
QVector<ValueControl *> m_curveTemperatures;
|
||||
QSet<QString> m_dirtyControls;
|
||||
bool m_loadingControls = false;
|
||||
QMap<QString, QVariantMap> m_savedControls;
|
||||
QVariantMap m_savedTray, m_savedPreferences;
|
||||
QComboBox *m_fastChoice, *m_batteryChoice;
|
||||
QCheckBox *m_autostart;
|
||||
QString m_autostartPath;
|
||||
QWidget *m_pendingBar = nullptr;
|
||||
QPushButton *m_saveButton, *m_undoButton;
|
||||
bool m_saving = false;
|
||||
Sensor m_trayMetric;
|
||||
QString m_trayMode;
|
||||
TrayStyle m_trayStyle;
|
||||
QComboBox *m_fanMode;
|
||||
TrayPage *m_trayPage;
|
||||
int m_chargeLimit = 100;
|
||||
bool m_chargeOverride = false;
|
||||
bool m_busy = false;
|
||||
bool m_sleeping = false;
|
||||
private Q_SLOTS:
|
||||
void sleepChanged(bool sleeping);
|
||||
};
|
||||
@@ -0,0 +1,242 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#include "hardware.h"
|
||||
#include "fan.h"
|
||||
#include "cpu.h"
|
||||
#include <QTest>
|
||||
#include <QTemporaryDir>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
|
||||
class HardwareTest : public QObject {
|
||||
Q_OBJECT
|
||||
void put(const QString &path, const QByteArray &value) {
|
||||
QVERIFY(QDir().mkpath(QFileInfo(path).absolutePath()));
|
||||
QFile file(path); QVERIFY(file.open(QIODevice::WriteOnly)); QCOMPARE(file.write(value), value.size());
|
||||
}
|
||||
private Q_SLOTS:
|
||||
void modelGate() {
|
||||
QTemporaryDir root;
|
||||
QVERIFY(!compatibilityError(root.path()).isEmpty());
|
||||
put(root.path() + "/class/dmi/id/sys_vendor", "Framework\n");
|
||||
put(root.path() + "/class/dmi/id/product_name", "Laptop 13\n");
|
||||
QVERIFY(!compatibilityError(root.path()).isEmpty());
|
||||
put(root.path() + "/class/dmi/id/product_name", "Laptop 13 Pro (Intel Core Ultra Series 3)\n");
|
||||
QVERIFY(compatibilityError(root.path()).isEmpty());
|
||||
}
|
||||
void missingReadingsAreNotZero() {
|
||||
QTemporaryDir root;
|
||||
QVERIFY(!readNumber(root.filePath("absent")));
|
||||
put(root.filePath("value"), "invalid\n"); QVERIFY(!readNumber(root.filePath("value")));
|
||||
put(root.filePath("value"), "0\n"); QCOMPARE(*readNumber(root.filePath("value")), 0.);
|
||||
}
|
||||
void batteryUnits() {
|
||||
QTemporaryDir root; const QString battery = root.path() + "/class/power_supply/BAT1/";
|
||||
put(battery + "type", "Battery"); put(battery + "current_now", "2000000");
|
||||
put(battery + "voltage_now", "16000000"); put(battery + "capacity", "60");
|
||||
QCOMPARE(batteryStatus(root.path())["watts"].toDouble(), 32.);
|
||||
put(battery + "power_now", "12300000");
|
||||
QCOMPARE(batteryStatus(root.path())["watts"].toDouble(), 12.3);
|
||||
QCOMPARE(batteryRate({{"watts", 12.3}, {"state", "Discharging"}}), std::optional<double>(-12.3));
|
||||
QCOMPARE(batteryRate({{"watts", 12.3}, {"state", "Charging"}}), std::optional<double>(12.3));
|
||||
QCOMPARE(batteryRate({{"watts", 0}, {"state", "Full"}}), std::optional<double>(0));
|
||||
QVERIFY(!batteryRate({{"watts", 12.3}, {"state", "Unknown"}}));
|
||||
put(battery + "charge_full", "4500000"); put(battery + "charge_full_design", "5000000");
|
||||
put(battery + "voltage_min_design", "16000000");
|
||||
QCOMPARE(batteryStatus(root.path())["fullMWh"].toDouble(), 72000.);
|
||||
QCOMPARE(batteryStatus(root.path())["health"].toDouble(), 90.);
|
||||
QVERIFY(batteryStatus(root.path())["capacityEstimated"].toBool());
|
||||
put(battery + "energy_full", "75000000"); put(battery + "energy_full_design", "75000000");
|
||||
QCOMPARE(batteryStatus(root.path())["fullMWh"].toDouble(), 75000.);
|
||||
QCOMPARE(batteryStatus(root.path())["health"].toDouble(), 100.);
|
||||
QVERIFY(!batteryStatus(root.path()).contains("capacityEstimated"));
|
||||
}
|
||||
void mainTemperatureSensors() {
|
||||
QTemporaryDir root;
|
||||
const QList<QPair<QString, QString>> fixtures{{"nvme", "Composite"}, {"nvme", "Sensor 1"},
|
||||
{"cros_ec", "peci-temp"}, {"cros_ec", "cpu_f75303@4d"}, {"coretemp", "Core 0"}, {"spd5118", ""}};
|
||||
for (int i = 0; i < fixtures.size(); ++i) {
|
||||
const QString dir = root.path() + "/class/hwmon/hwmon" + QString::number(i) + "/";
|
||||
put(dir + "name", fixtures[i].first.toUtf8()); put(dir + "temp1_label", fixtures[i].second.toUtf8());
|
||||
put(dir + "temp1_input", "45000");
|
||||
}
|
||||
const auto sensors = temperatureSensors(root.path()); QCOMPARE(sensors.size(), 6);
|
||||
for (const auto &sensor : sensors) {
|
||||
const bool main = sensor.name == "NVMe (composite)" || sensor.name == "CPU (PECI)"
|
||||
|| sensor.name == "Memory (SPD)";
|
||||
QCOMPARE(sensor.primary, main); QCOMPARE(sensorValue(sensor), std::optional<double>(45.));
|
||||
}
|
||||
}
|
||||
void fanValidationAndCurve() {
|
||||
QVERIFY(validateFan({{"mode", "auto"}}).isEmpty());
|
||||
QVERIFY(validateFan({{"mode", "manual"}, {"duty", 0}}).isEmpty());
|
||||
QVERIFY(!validateFan({{"mode", "manual"}, {"duty", -1}}).isEmpty());
|
||||
QVERIFY(validateFan({{"mode", "curve"}, {"curve", QVariantList{0, 0, 20, 100}}}).isEmpty());
|
||||
QCOMPARE(curveDuty(40, {0, 0, 20, 100}), 0.);
|
||||
QVERIFY(!validateFan({{"mode", "curve"}, {"curve", QVariantList{30, 70, 40, 100}}}).isEmpty());
|
||||
QVERIFY(!validateFan({{"mode", "curve"}, {"curve", QVariantList{30, 40, 70, 90}}}).isEmpty());
|
||||
QVERIFY(validateFan({{"mode", "curve"}, {"curve", QVariantList{30, 40, 70, 100}}}).isEmpty());
|
||||
QCOMPARE(curveDuty(55, {30, 40, 70, 100}), 40.);
|
||||
QCOMPARE(curveDuty(62.5, {30, 40, 70, 100}), 55.);
|
||||
QCOMPARE(curveDuty(100, {30, 40, 70, 100}), 100.);
|
||||
QCOMPARE(curveDuty(60, {30, 40, 70, 100}, {40, 60, 75, 85}), 40.);
|
||||
QCOMPARE(curveDuty(67.5, {30, 40, 70, 100}, {40, 60, 75, 85}), 55.);
|
||||
QVariantMap config{{"mode", "curve"}, {"curve", QVariantList{30, 40, 70, 100}}, {"temperatures", QVariantList{40, 60, 75, 85}}};
|
||||
QVERIFY(validateFan(config).isEmpty());
|
||||
config["temperatures"] = QVariantList{40, 60, 60, 85}; QVERIFY(!validateFan(config).isEmpty());
|
||||
config["temperatures"] = QVariantList{40, 60, 75, 90}; QVERIFY(!validateFan(config).isEmpty());
|
||||
}
|
||||
void cpuRangeAndApply() {
|
||||
QTemporaryDir root; const QString policy = root.path() + "/devices/system/cpu/cpufreq/policy0/";
|
||||
put(policy + "cpuinfo_min_freq", "400000"); put(policy + "cpuinfo_max_freq", "4800000");
|
||||
put(policy + "scaling_min_freq", "400000"); put(policy + "scaling_max_freq", "3000000");
|
||||
QVERIFY(!setCpuLimits(3500, 3000, root.path()).isEmpty());
|
||||
QCOMPARE(readText(policy + "scaling_max_freq"), QString("3000000"));
|
||||
QVERIFY(setCpuLimits(1000, 4000, root.path()).isEmpty());
|
||||
QCOMPARE(readText(policy + "scaling_min_freq"), QString("1000000"));
|
||||
QCOMPARE(readText(policy + "scaling_max_freq"), QString("4000000"));
|
||||
}
|
||||
void hybridCpuProfiles() {
|
||||
QTemporaryDir root;
|
||||
const QString base = root.path() + "/devices/system/cpu/cpufreq/";
|
||||
for (int i = 0; i < 2; ++i) {
|
||||
const QString p = base + "policy" + QString::number(i) + "/";
|
||||
put(p + "cpuinfo_min_freq", "400000"); put(p + "cpuinfo_max_freq", i ? "3300000" : "4800000");
|
||||
put(p + "scaling_min_freq", "400000"); put(p + "scaling_max_freq", "3000000");
|
||||
put(p + "scaling_driver", "intel_pstate");
|
||||
put(p + "scaling_governor", "powersave"); put(p + "energy_performance_preference", "balance_power");
|
||||
put(p + "scaling_available_governors", "powersave performance");
|
||||
put(p + "energy_performance_available_preferences", "performance balance_performance balance_power power");
|
||||
}
|
||||
QVariantMap profile{{"frequencyOverride", true}, {"minimum", 1000}, {"maximum", 4500}, {"governor", "powersave"}, {"preference", "balance_power"}};
|
||||
QCOMPARE(cpuLimits(root.path())["high"].toInt(), 4800);
|
||||
QCOMPARE(cpuLimits(root.path())["capped"].toInt(), 2);
|
||||
QVERIFY(applyCpuProfile(profile, root.path()).isEmpty());
|
||||
QCOMPARE(readText(base + "policy0/scaling_max_freq"), QString("4500000"));
|
||||
QCOMPARE(readText(base + "policy1/scaling_max_freq"), QString("3300000"));
|
||||
QCOMPARE(cpuLimits(root.path())["capped"].toInt(), 1); // An E-core at its own ceiling isn't capped.
|
||||
QCOMPARE(readText(base + "policy1/energy_performance_preference"), QString("balance_power"));
|
||||
profile["minimum"] = 4000;
|
||||
QVERIFY(applyCpuProfile(profile, root.path()).isEmpty());
|
||||
QCOMPARE(readText(base + "policy1/scaling_min_freq"), QString("3300000"));
|
||||
profile["governor"] = "performance";
|
||||
QVERIFY(applyCpuProfile(profile, root.path()).isEmpty());
|
||||
QCOMPARE(readText(base + "policy0/scaling_governor"), QString("powersave"));
|
||||
profile["preference"] = "performance";
|
||||
QVERIFY(applyCpuProfile(profile, root.path()).isEmpty());
|
||||
profile["maximum"] = 99999;
|
||||
QVERIFY(!applyCpuProfile(profile, root.path()).isEmpty());
|
||||
}
|
||||
void independentCpuOverrides() {
|
||||
QTemporaryDir root; const QString p = root.path() + "/devices/system/cpu/cpufreq/policy0/";
|
||||
const QVariantMap automatic{{"frequencyOverride", false}, {"governor", "auto"}, {"preference", "auto"}};
|
||||
QVERIFY(applyCpuProfile(automatic, root.path()).isEmpty()); // No CPU files are required for a no-op.
|
||||
put(p + "scaling_driver", "intel_pstate"); put(p + "scaling_governor", "powersave");
|
||||
put(p + "energy_performance_preference", "balance_power");
|
||||
put(p + "scaling_available_governors", "powersave performance");
|
||||
put(p + "energy_performance_available_preferences", "performance balance_power balance_performance power");
|
||||
put(p + "scaling_min_freq", "555000"); put(p + "scaling_max_freq", "2345000");
|
||||
put(p + "cpuinfo_min_freq", "400000"); put(p + "cpuinfo_max_freq", "4800000");
|
||||
auto profile = automatic; profile["governor"] = "performance";
|
||||
QVERIFY(applyCpuProfile(profile, root.path()).isEmpty());
|
||||
QCOMPARE(readText(p + "scaling_governor"), QString("powersave"));
|
||||
QCOMPARE(readText(p + "energy_performance_preference"), QString("balance_power"));
|
||||
put(p + "energy_performance_preference", "performance");
|
||||
QVERIFY(applyCpuProfile(profile, root.path()).isEmpty());
|
||||
QCOMPARE(readText(p + "scaling_governor"), QString("performance"));
|
||||
// Simulate another controller switching governor/EPP after a power-profile change.
|
||||
put(p + "scaling_governor", "powersave"); put(p + "energy_performance_preference", "power");
|
||||
QVERIFY(applyCpuProfile(profile, root.path()).isEmpty());
|
||||
QCOMPARE(readText(p + "scaling_governor"), QString("powersave"));
|
||||
QCOMPARE(readText(p + "energy_performance_preference"), QString("power"));
|
||||
QCOMPARE(readText(p + "scaling_min_freq"), QString("555000"));
|
||||
QCOMPARE(readText(p + "scaling_max_freq"), QString("2345000"));
|
||||
profile = automatic; profile["preference"] = "balance_power";
|
||||
put(p + "scaling_governor", "performance");
|
||||
QVERIFY(!applyCpuProfile(profile, root.path()).isEmpty()); // Auto governor must not be silently changed.
|
||||
QCOMPARE(readText(p + "scaling_governor"), QString("performance"));
|
||||
QCOMPARE(readText(p + "energy_performance_preference"), QString("power"));
|
||||
profile = automatic; profile["frequencyOverride"] = true; profile["minimum"] = 1000; profile["maximum"] = 4000;
|
||||
QVERIFY(applyCpuProfile(profile, root.path()).isEmpty());
|
||||
QCOMPARE(readText(p + "scaling_governor"), QString("performance"));
|
||||
QCOMPARE(readText(p + "energy_performance_preference"), QString("power"));
|
||||
QCOMPARE(readText(p + "scaling_max_freq"), QString("4000000"));
|
||||
QVERIFY(applyCpuProfile(automatic, root.path()).isEmpty());
|
||||
QCOMPARE(readText(p + "scaling_max_freq"), QString("4000000")); // Releasing control is not a reset.
|
||||
}
|
||||
void cpuConfigMigration() {
|
||||
const QVariantMap profile{{"minimum", 1000}, {"maximum", 3000}, {"governor", "performance"}, {"preference", "performance"}};
|
||||
QVariantMap old{{"enabled", false}, {"separate", true}, {"battery", profile}, {"ac", profile}};
|
||||
auto config = normalizeCpuConfig(old); QVERIFY(!config.contains("enabled")); QVERIFY(!hasCpuOverrides(config));
|
||||
QCOMPARE(config["battery"].toMap()["governor"].toString(), QString("auto"));
|
||||
old["enabled"] = true; config = normalizeCpuConfig(old); QVERIFY(hasCpuOverrides(config));
|
||||
QVERIFY(config["battery"].toMap()["frequencyOverride"].toBool());
|
||||
QCOMPARE(normalizeCpuConfig(config), config);
|
||||
}
|
||||
void independentCoreGroups() {
|
||||
QTemporaryDir root;
|
||||
put(root.path() + "/bus/event_source/devices/cpu_core/cpus", "0");
|
||||
put(root.path() + "/bus/event_source/devices/cpu_atom/cpus", "1-2");
|
||||
const QString base = root.path() + "/devices/system/cpu/cpufreq/";
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
const auto path = base + "policy" + QString::number(i) + "/";
|
||||
put(path + "related_cpus", QByteArray::number(i));
|
||||
put(path + "cpuinfo_min_freq", "400000"); put(path + "cpuinfo_max_freq", i == 0 ? "4800000" : i == 1 ? "3700000" : "3300000");
|
||||
put(path + "scaling_min_freq", "400000"); put(path + "scaling_max_freq", "3000000");
|
||||
}
|
||||
QCOMPARE(cpuPolicyGroups(root.path())["e"].size(), 2);
|
||||
QVariantMap bounds{{"p", QVariantMap{{"frequencyOverride", true}, {"minimum", 1000}, {"maximum", 4500}}},
|
||||
{"e", QVariantMap{{"frequencyOverride", false}, {"minimum", 400}, {"maximum", 3500}}}};
|
||||
QVariantMap profile{{"bounds", bounds}, {"governor", "auto"}, {"preference", "auto"}};
|
||||
QVERIFY(applyCpuProfile(profile, root.path()).isEmpty());
|
||||
QCOMPARE(readText(base + "policy0/scaling_max_freq"), QString("4500000"));
|
||||
QCOMPARE(readText(base + "policy1/scaling_max_freq"), QString("3000000"));
|
||||
bounds["p"] = QVariantMap{{"frequencyOverride", false}};
|
||||
bounds["e"] = QVariantMap{{"frequencyOverride", true}, {"minimum", 500}, {"maximum", 3500}};
|
||||
profile["bounds"] = bounds;
|
||||
QVERIFY(applyCpuProfile(profile, root.path()).isEmpty());
|
||||
QCOMPARE(readText(base + "policy0/scaling_max_freq"), QString("4500000"));
|
||||
QCOMPARE(readText(base + "policy1/scaling_max_freq"), QString("3500000"));
|
||||
QCOMPARE(readText(base + "policy2/scaling_max_freq"), QString("3300000"));
|
||||
QVERIFY(hasCpuOverrides({{"battery", profile}}));
|
||||
QCOMPARE(normalizeCpuConfig({{"battery", profile}})["battery"].toMap()["bounds"], QVariant(bounds));
|
||||
put(base + "policy1/related_cpus", "0 1");
|
||||
QVERIFY(cpuPolicyGroups(root.path()).isEmpty());
|
||||
QVERIFY(!applyCpuProfile(profile, root.path()).isEmpty());
|
||||
}
|
||||
void powerSourceAndProfileSelection() {
|
||||
QTemporaryDir root;
|
||||
QVERIFY(!onAcPower(root.path()).has_value());
|
||||
const QString ac = root.path() + "/class/power_supply/ACAD/";
|
||||
put(ac + "type", "Mains"); put(ac + "online", "0");
|
||||
QCOMPARE(onAcPower(root.path()), std::optional<bool>(false));
|
||||
put(ac + "online", "1");
|
||||
QCOMPARE(onAcPower(root.path()), std::optional<bool>(true));
|
||||
const QVariantMap battery{{"maximum", 2500}}, plugged{{"maximum", 4500}};
|
||||
QVariantMap config{{"enabled", true}, {"separate", true}, {"battery", battery}, {"ac", plugged}};
|
||||
QCOMPARE(cpuProfile(config, false), battery); QCOMPARE(cpuProfile(config, true), plugged);
|
||||
config["separate"] = false;
|
||||
QCOMPARE(cpuProfile(config, true), battery);
|
||||
}
|
||||
void fanRefusesMissingOrHotSensors() {
|
||||
QTemporaryDir root; const QString ec = root.path() + "/class/hwmon/hwmon7/";
|
||||
put(ec + "name", "cros_ec"); put(ec + "pwm1_enable", "2"); put(ec + "pwm1", "0"); put(ec + "fan1_fault", "0");
|
||||
const QVariantMap config{{"mode", "manual"}, {"duty", 50}};
|
||||
double last = -1;
|
||||
QVERIFY(!updateFan({{"mode", "auto"}}, last, root.path()).isEmpty());
|
||||
QVERIFY(!updateFan(config, last, root.path()).isEmpty());
|
||||
QCOMPARE(readText(ec + "pwm1_enable"), QString("2"));
|
||||
for (int i = 1; i <= 5; ++i) {
|
||||
const QString stem = ec + "temp" + QString::number(i);
|
||||
put(stem + "_input", "40000"); put(stem + "_fault", "0"); put(stem + "_crit", "90000");
|
||||
}
|
||||
QVERIFY(updateFan(config, last, root.path()).isEmpty());
|
||||
QCOMPARE(readText(ec + "pwm1_enable"), QString("1"));
|
||||
QCOMPARE(last, 50.);
|
||||
put(ec + "temp3_input", "89000");
|
||||
QVERIFY(!updateFan(config, last, root.path()).isEmpty());
|
||||
put(ec + "temp3_input", "40000"); put(ec + "temp1_fault", "1");
|
||||
QVERIFY(!updateFan(config, last, root.path()).isEmpty());
|
||||
}
|
||||
};
|
||||
QTEST_GUILESS_MAIN(HardwareTest)
|
||||
#include "test-hardware.moc"
|
||||
@@ -0,0 +1,361 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#include "window.h"
|
||||
#include <QTest>
|
||||
#include <QTemporaryDir>
|
||||
#include <QTabWidget>
|
||||
#include <QPushButton>
|
||||
#include <QSignalSpy>
|
||||
#include <QToolButton>
|
||||
#include "legend.h"
|
||||
#include "tray.h"
|
||||
#include <cmath>
|
||||
#include <QTextDocument>
|
||||
#include <QTextBlock>
|
||||
#include <QTextFragment>
|
||||
#include "traypage.h"
|
||||
#include "colorbutton.h"
|
||||
#include <QMessageBox>
|
||||
|
||||
class WindowTest : public QObject {
|
||||
Q_OBJECT
|
||||
private Q_SLOTS:
|
||||
void logicalTicks() {
|
||||
const auto freq = AxisTicks::covering(0, 4681, 4);
|
||||
QCOMPARE(freq.minimum, 0.); QCOMPARE(freq.maximum, 6000.); QCOMPARE(freq.step, 2000.);
|
||||
const auto rate = AxisTicks::covering(-13, 42, 4);
|
||||
QCOMPARE(rate.minimum, -20.); QCOMPARE(rate.maximum, 60.); QCOMPARE(rate.step, 20.);
|
||||
QCOMPARE(timeTickStep(3600000, 6), 600000.);
|
||||
QCOMPARE(timeTickStep(3600000, 3), 1800000.);
|
||||
QCOMPARE(ageLabel(7200000), QString("2h"));
|
||||
QCOMPARE(ageLabel(1200000), QString("20min"));
|
||||
QCOMPARE(ageLabel(5000), QString("5s"));
|
||||
QCOMPARE(ageLabel(0), QString("Now"));
|
||||
}
|
||||
void historyHoverAndSleep() {
|
||||
Chart chart("%"); chart.addSeries("battery", "Charge level"); chart.setSelected("battery", true);
|
||||
chart.addSeries("rate", "Battery rate", "W"); chart.setSelected("rate", true);
|
||||
chart.sample({{"battery", 75}, {"rate", -10}}, 30000, 100000);
|
||||
chart.setPowerState(false, true, 100000);
|
||||
QVERIFY(chart.readingAt(100000).contains("Charge level: 75 %"));
|
||||
QVERIFY(chart.readingAt(100000).contains("Battery rate: -10 W"));
|
||||
QVERIFY(chart.readingAt(100000).contains("Charger connected"));
|
||||
chart.setPowerState(true, true, 105000);
|
||||
QVERIFY(chart.readingAt(106000).contains("Asleep"));
|
||||
QVERIFY(chart.readingAt(106000).contains("Battery rate: —"));
|
||||
QVERIFY(!chart.readingAt(106000).contains("Charger connected"));
|
||||
chart.setPowerState(false, false, 110000);
|
||||
QVERIFY(chart.readingAt(111000).contains("Charge level: —")); // Don't reach back across even a short sleep.
|
||||
chart.sample({{"battery", 74}, {"rate", -8}}, 30000, 112000);
|
||||
QVERIFY(chart.readingAt(112000).contains("Charge level: 74 %"));
|
||||
chart.setSelected("rate", false);
|
||||
QVERIFY(!chart.readingAt(112000).contains("Battery rate"));
|
||||
QVERIFY(chart.readingAt(200000).contains("Charge level: —"));
|
||||
for (int i = 0; i < 601; ++i) chart.sample({}, 30000, 200000 + i * 30000);
|
||||
QVERIFY(!chart.readingAt(106000).contains("Asleep")); // Annotations expire with samples.
|
||||
}
|
||||
void clickableLegend() {
|
||||
Chart chart("MHz"); chart.addSeries("cpu"); chart.setSelected("cpu", true);
|
||||
Legend legend(&chart, {{"cpu", "CPU average", {}, "MHz"}});
|
||||
QCOMPARE(legend.enabledCount(), 1);
|
||||
QSignalSpy changed(&legend, &Legend::selectionChanged);
|
||||
QVERIFY(QMetaObject::invokeMethod(&legend, "linkActivated", Q_ARG(QString, "0")));
|
||||
QVERIFY(!chart.selected("cpu")); QCOMPARE(changed.count(), 1);
|
||||
QVERIFY(legend.text().contains("line-through"));
|
||||
QTextDocument doc; doc.setHtml(legend.text());
|
||||
bool strike = false;
|
||||
for (auto block = doc.begin(); block.isValid(); block = block.next()) for (auto it = block.begin(); !it.atEnd(); ++it) {
|
||||
const auto format = it.fragment().charFormat(); QVERIFY(!format.fontUnderline()); strike |= format.fontStrikeOut();
|
||||
}
|
||||
QVERIFY(strike);
|
||||
QVERIFY(QMetaObject::invokeMethod(&legend, "linkActivated", Q_ARG(QString, "0")));
|
||||
QVERIFY(chart.selected("cpu")); QCOMPARE(legend.enabledCount(), 1);
|
||||
}
|
||||
void cpuUsageAndTray() {
|
||||
CpuUsage usage;
|
||||
QVERIFY(!usage.sample("cpu 10 0 10 80 0 0 0 0 10 0"));
|
||||
QCOMPARE(usage.sample("cpu 30 0 10 160 0 0 0 0 30 0"), std::optional<double>(20.));
|
||||
usage.reset(); QVERIFY(!usage.sample("cpu 30 0 10 160 0 0 0 0"));
|
||||
QVERIFY(!usage.sample("cpu 1 0 1 1 0 0 0 0")); // Counter reset.
|
||||
QVERIFY(!usage.sample("not available"));
|
||||
TrayStyle style; style.borderColor = Qt::white; style.backgroundColor = Qt::black;
|
||||
style.lineColor = Qt::cyan; style.fillColor = Qt::blue; style.outsideColor = Qt::red;
|
||||
for (bool graph : {false, true}) for (const auto &values : {QVector<QPointF>{}, QVector<QPointF>{{0, 10}, {1, 20}, {2, NAN}, {3, 30}}}) {
|
||||
const auto icon = telemetryIcon(graph, values, "%", style);
|
||||
QVERIFY(!icon.isNull()); QVERIFY(!icon.pixmap(24, 24).isNull());
|
||||
}
|
||||
}
|
||||
void trayBoundsAndAppearance() {
|
||||
TrayStyle style; style.borderColor = Qt::white; style.backgroundColor = Qt::black;
|
||||
style.lineColor = Qt::green; style.fillColor = Qt::blue; style.outsideColor = Qt::red; style.fill = false;
|
||||
auto render = [&] { return telemetryIcon(true, {{0, 120}, {10, 120}}, "%", style).pixmap(64, 64).toImage(); };
|
||||
auto image = render();
|
||||
QCOMPARE(image.pixelColor(32, 1), QColor(Qt::white));
|
||||
QCOMPARE(image.pixelColor(32, 3), QColor(Qt::red));
|
||||
QCOMPARE(image.pixelColor(32, 32), QColor(Qt::black));
|
||||
style.clamp = false; image = render(); QCOMPARE(image.pixelColor(32, 3), QColor(Qt::black));
|
||||
style.border = false; style.transparent = true; image = render(); QCOMPARE(image.pixelColor(32, 1).alpha(), 0);
|
||||
style.clamp = true; style.overflowColor = false; image = render(); QCOMPARE(image.pixelColor(32, 1), QColor(Qt::green));
|
||||
QVERIFY(trayPlotRect(true).width() < trayPlotRect(false).width());
|
||||
style.fill = true;
|
||||
image = telemetryIcon(true, {{0, 50}, {10, 50}}, "%", style).pixmap(64, 64).toImage();
|
||||
QCOMPARE(image.pixelColor(32, 50), QColor(Qt::blue));
|
||||
QCOMPARE(image.pixelColor(32, 10).alpha(), 0);
|
||||
}
|
||||
void sharedHistoryAndTrayScales() {
|
||||
Chart chart("MHz"); chart.addSeries("cpu"); chart.addSeries("gpu");
|
||||
chart.sample({{"cpu", 1000}, {"gpu", 500}}, 1000, 1000);
|
||||
chart.sample({{"cpu", 1200}, {"gpu", 600}}, 1000, 2000);
|
||||
QCOMPARE(chart.history("gpu").size(), 2); // Even never-selected sensors already have history.
|
||||
chart.setSelected("cpu", true); chart.setSelected("cpu", false);
|
||||
QCOMPARE(chart.history("cpu").last().y(), 1200.);
|
||||
chart.setPowerState(true, {}, 2100); chart.setPowerState(false, {}, 2500);
|
||||
chart.sample({{"cpu", 1100}}, 1000, 2600);
|
||||
QVERIFY(std::isnan(chart.history("cpu")[2].y()));
|
||||
QTemporaryDir root; QSettings settings(root.filePath("tray.ini"), QSettings::IniFormat);
|
||||
TrayPage page(settings, {{"cpu", "CPU", {}, "MHz"}, {"temp", "CPU temperature", {}, "°C"}, {"rate", "Battery rate", {}, "W"}});
|
||||
const auto original = page.draft();
|
||||
auto *mode = page.findChild<QComboBox *>("trayDisplayMode"); mode->setCurrentIndex(1);
|
||||
auto *metric = page.findChild<QComboBox *>("trayMetric");
|
||||
auto *minimum = page.findChild<QDoubleSpinBox *>("trayMinimum"); auto *maximum = page.findChild<QDoubleSpinBox *>("trayMaximum");
|
||||
maximum->setValue(4500); QCOMPARE(page.iconStyle().maximum, 4500.);
|
||||
metric->setCurrentIndex(1); minimum->setValue(30); maximum->setValue(90);
|
||||
QCOMPARE(page.iconStyle().minimum, 30.); QCOMPARE(page.iconStyle().maximum, 90.);
|
||||
metric->setCurrentIndex(0); QCOMPARE(page.iconStyle().minimum, 0.); QCOMPARE(page.iconStyle().maximum, 4500.);
|
||||
metric->setCurrentIndex(1); QCOMPARE(page.iconStyle().minimum, 30.); QCOMPARE(page.iconStyle().maximum, 90.);
|
||||
metric->setCurrentIndex(2); minimum->setValue(-40); maximum->setValue(65);
|
||||
QCOMPARE(page.iconStyle().minimum, -40.); QCOMPARE(page.iconStyle().maximum, 65.);
|
||||
metric->setCurrentIndex(1); metric->setCurrentIndex(2);
|
||||
QCOMPARE(page.iconStyle().minimum, -40.); QCOMPARE(page.iconStyle().maximum, 65.);
|
||||
const QString shot = qEnvironmentVariable("FRAMEWORK_TOOLS_TRAY_SCREENSHOT");
|
||||
if (!shot.isEmpty()) { page.resize(700, 780); page.show(); QTest::qWait(10); QVERIFY(page.grab().save(shot)); }
|
||||
QVERIFY(settings.allKeys().isEmpty()); // Editing never writes persistent settings.
|
||||
page.load(original);
|
||||
QCOMPARE(mode->currentIndex(), 0); QCOMPARE(metric->currentIndex(), 0);
|
||||
QVERIFY(!settings.contains("tray/scales/rate/minimum"));
|
||||
}
|
||||
void chartRendering() {
|
||||
Chart chart("%"); chart.resize(850, 240);
|
||||
chart.addSeries("battery", "Battery"); chart.setSelected("battery", true);
|
||||
chart.addSeries("rate", "Charge / discharge", "W"); chart.setSelected("rate", true);
|
||||
const qint64 now = QDateTime::currentMSecsSinceEpoch();
|
||||
for (int i = 0; i <= 120; ++i) {
|
||||
const auto t = now - 3600000 + i * 30000;
|
||||
if (i == 20) chart.setPowerState(true, {}, t);
|
||||
if (i == 40) chart.setPowerState(false, false, t);
|
||||
if (i == 60) chart.setPowerState(false, true, t);
|
||||
if (i < 20 || i >= 40) chart.sample({{"battery", 50 + i * .25}, {"rate", i < 60 ? -12. : 35.}}, 30000, t);
|
||||
}
|
||||
chart.show(); QTest::qWait(10);
|
||||
const QString screenshot = qEnvironmentVariable("FRAMEWORK_TOOLS_CHART_SCREENSHOT");
|
||||
if (!screenshot.isEmpty()) QVERIFY(chart.grab().save(screenshot));
|
||||
QPalette dark = chart.palette(); dark.setColor(QPalette::Window, QColor("#232629"));
|
||||
dark.setColor(QPalette::Text, QColor("#eff0f1")); dark.setColor(QPalette::Mid, QColor("#45494c"));
|
||||
chart.setPalette(dark); chart.setAutoFillBackground(true);
|
||||
if (!screenshot.isEmpty()) QVERIFY(chart.grab().save(screenshot + ".dark.png"));
|
||||
const auto before = chart.grab().toImage();
|
||||
QTest::mouseMove(&chart, QPoint(400, 100)); QTest::qWait(10);
|
||||
QVERIFY(chart.grab().toImage() != before);
|
||||
if (!screenshot.isEmpty()) QVERIFY(chart.grab().save(screenshot + ".hover.png"));
|
||||
QEvent leave(QEvent::Leave); QApplication::sendEvent(&chart, &leave);
|
||||
chart.resize(320, 175); QTest::qWait(10);
|
||||
QVERIFY(!chart.grab().isNull());
|
||||
}
|
||||
void hoverDoesNotAdvanceChart() {
|
||||
Chart chart("MHz"); chart.resize(850, 240);
|
||||
chart.addSeries("cpu"); chart.setSelected("cpu", true);
|
||||
const qint64 now = QDateTime::currentMSecsSinceEpoch();
|
||||
chart.sample({{"cpu", 1000}}, 1000, now - 2000);
|
||||
chart.sample({{"cpu", 3000}}, 1000, now - 1000);
|
||||
chart.sample({{"cpu", 2000}}, 1000, now);
|
||||
chart.show(); QTest::qWait(10);
|
||||
QTest::mouseMove(&chart, QPoint(1, 1));
|
||||
const auto before = chart.grab().toImage();
|
||||
QTest::mouseMove(&chart, QPoint(400, 100));
|
||||
QVERIFY(chart.hoverTime().has_value());
|
||||
QVERIFY(chart.grab().toImage() != before);
|
||||
QTest::qWait(100);
|
||||
QEvent leave(QEvent::Leave); QApplication::sendEvent(&chart, &leave);
|
||||
QCOMPARE(chart.grab().toImage(), before);
|
||||
chart.sample({{"cpu", 2500}}, 1000, now + 1000);
|
||||
QVERIFY(chart.grab().toImage() != before);
|
||||
}
|
||||
void sliderAndNumberStaySynchronized() {
|
||||
ValueControl control;
|
||||
control.setRange(400, 4800); control.setSuffix(" MHz"); control.setSingleStep(100);
|
||||
auto *slider = control.findChild<QSlider *>(); auto *number = control.findChild<QSpinBox *>();
|
||||
QVERIFY(slider); QVERIFY(number);
|
||||
QCOMPARE(slider->minimum(), 400); QCOMPARE(slider->maximum(), 4800);
|
||||
QCOMPARE(slider->singleStep(), 100);
|
||||
QSignalSpy changed(&control, &ValueControl::valueChanged);
|
||||
slider->setValue(4500);
|
||||
QCOMPARE(number->value(), 4500); QCOMPARE(control.value(), 4500); QCOMPARE(changed.count(), 1);
|
||||
number->setValue(1234);
|
||||
QCOMPARE(slider->value(), 1234); QCOMPARE(changed.count(), 2);
|
||||
control.setRange(1500, 3300);
|
||||
QCOMPARE(slider->value(), 1500); QCOMPARE(number->value(), 1500);
|
||||
control.setValue(5000);
|
||||
QCOMPARE(slider->value(), 3300); QCOMPARE(control.value(), 3300);
|
||||
control.setEnabled(false);
|
||||
QVERIFY(!slider->isEnabled()); QVERIFY(!number->isEnabled());
|
||||
control.setEnabled(true);
|
||||
control.setRange(0, 75); control.setSuffix(" W"); control.setSpecialValueText("Firmware default");
|
||||
slider->setValue(0);
|
||||
QCOMPARE(number->text(), QString("Firmware default"));
|
||||
}
|
||||
void pagesAndReadOnlyStartup() {
|
||||
QTemporaryDir config;
|
||||
qputenv("XDG_CONFIG_HOME", config.path().toUtf8());
|
||||
QSettings settings("fedora-tools", "framework-laptop-tools");
|
||||
settings.setValue("sampling/fast", 0);
|
||||
settings.setValue("sampling/battery", -5);
|
||||
Window window; window.show(); QTest::qWait(500);
|
||||
QCOMPARE(settings.value("sampling/fast").toInt(), 1000);
|
||||
QCOMPARE(settings.value("sampling/battery").toInt(), 30000);
|
||||
auto *tabs = window.findChild<QTabWidget *>(); QVERIFY(tabs); QCOMPARE(tabs->count(), 7);
|
||||
QCOMPARE(tabs->tabText(1), QString("Lighting"));
|
||||
QCOMPARE(tabs->tabText(2), QString("Cooling"));
|
||||
QCOMPARE(tabs->tabText(3), QString("Battery"));
|
||||
QCOMPARE(tabs->tabText(4), QString("CPU"));
|
||||
QCOMPARE(tabs->tabText(5), QString("Tray icon"));
|
||||
QCOMPARE(window.findChildren<Chart *>().size(), 4);
|
||||
for (auto *toggle : window.findChildren<QToolButton *>("extraTemperatureSensors")) QVERIFY(!toggle->isChecked());
|
||||
const QString monitor = qEnvironmentVariable("FRAMEWORK_TOOLS_MONITOR_SCREENSHOT");
|
||||
if (!monitor.isEmpty()) QVERIFY(window.grab().save(monitor));
|
||||
tabs->setCurrentIndex(1); QTest::qWait(50);
|
||||
auto *powerAuto = window.findChild<QCheckBox *>("powerAuto"); QVERIFY(powerAuto);
|
||||
auto *brightness = window.findChild<ValueControl *>("powerBrightness"); QVERIFY(brightness);
|
||||
powerAuto->setChecked(true); QVERIFY(!brightness->isEnabledTo(brightness->parentWidget()));
|
||||
powerAuto->setChecked(false); QVERIFY(brightness->isEnabledTo(brightness->parentWidget()));
|
||||
brightness->setValue(42);
|
||||
auto *keyboard = window.findChild<ValueControl *>("keyboardBrightness"); QVERIFY(keyboard); keyboard->setValue(37);
|
||||
QTest::qWait(2300); // Automatic firmware refresh must not discard unsaved lighting edits.
|
||||
QCOMPARE(brightness->value(), 42); QVERIFY(!powerAuto->isChecked()); QCOMPARE(keyboard->value(), 37);
|
||||
const QString screenshot = qEnvironmentVariable("FRAMEWORK_TOOLS_SCREENSHOT");
|
||||
if (!screenshot.isEmpty()) QVERIFY(window.grab().save(screenshot));
|
||||
tabs->setCurrentIndex(2); QTest::qWait(50);
|
||||
}
|
||||
void cpuProfileDrafts() {
|
||||
CpuPage page;
|
||||
QVERIFY(!page.findChild<QCheckBox *>("enableCpuOverrides"));
|
||||
auto *battery = page.findChild<QGroupBox *>("batteryCpuProfile"); QVERIFY(battery->isEnabled());
|
||||
auto *frequency = battery->findChild<QCheckBox *>("overrideFrequency"); QVERIFY(frequency); QVERIFY(!frequency->isChecked());
|
||||
QVERIFY(!battery->findChild<ValueControl *>()->isEnabled());
|
||||
QCOMPARE(battery->findChild<QComboBox *>("cpuGovernor")->currentText(), QString("auto"));
|
||||
QCOMPARE(battery->findChild<QComboBox *>("cpuPreference")->currentText(), QString("auto"));
|
||||
for (auto *spin : page.findChildren<ValueControl *>()) spin->setRange(400, 10000);
|
||||
for (auto *combo : page.findChildren<QComboBox *>()) {
|
||||
if (combo->count() == 1) combo->addItems({"powersave", "performance", "balance_power"});
|
||||
}
|
||||
const QVariantMap profile{{"minimum", 1000}, {"maximum", 2000}, {"governor", "powersave"}, {"preference", "balance_power"}};
|
||||
const QVariantMap saved{{"enabled", true}, {"separate", false}, {"battery", profile}};
|
||||
page.load(saved, true);
|
||||
auto *split = page.findChild<QCheckBox *>("separateCpuProfiles"); QVERIFY(split);
|
||||
QVERIFY(!page.dirty()); QVERIFY(!page.draft().contains("ac"));
|
||||
split->setChecked(true);
|
||||
QCOMPARE(page.draft()["ac"], page.draft()["battery"]);
|
||||
QVERIFY(frequency->isChecked());
|
||||
auto *ac = page.findChild<QGroupBox *>("acCpuProfile"); QVERIFY(ac);
|
||||
auto boxes = ac->findChildren<ValueControl *>(); QVERIFY(boxes.size() >= 2);
|
||||
const bool grouped = page.draft()["ac"].toMap().contains("bounds");
|
||||
boxes[grouped ? 3 : 1]->setValue(3000);
|
||||
split->setChecked(false);
|
||||
QVERIFY(page.dirty()); QVERIFY(!page.draft().contains("ac"));
|
||||
page.load(saved); // Background refresh must not replace unsaved edits.
|
||||
split->setChecked(true);
|
||||
QCOMPARE(grouped ? page.draft()["ac"].toMap()["bounds"].toMap()["p"].toMap()["maximum"].toInt()
|
||||
: page.draft()["ac"].toMap()["maximum"].toInt(), 3000);
|
||||
split->setChecked(false);
|
||||
const auto joined = page.draft();
|
||||
page.load(joined, true); // Successful save commits removal of the AC copy.
|
||||
split->setChecked(true);
|
||||
QCOMPARE(page.draft()["ac"], page.draft()["battery"]);
|
||||
QVERIFY(page.dirty()); page.undo();
|
||||
QVERIFY(!page.dirty()); QVERIFY(!page.draft().contains("ac"));
|
||||
const QString screenshot = qEnvironmentVariable("FRAMEWORK_TOOLS_CPU_SCREENSHOT");
|
||||
if (!screenshot.isEmpty()) {
|
||||
split->setChecked(true); page.resize(1000, 720); page.show(); QTest::qWait(50);
|
||||
QVERIFY(page.grab().save(screenshot));
|
||||
}
|
||||
}
|
||||
void colorSwatchUsesRgb() {
|
||||
ColorButton button; button.setColor(QColor(0, 255, 0, 80)); button.resize(button.sizeHint()); button.show();
|
||||
QTest::qWait(10);
|
||||
QVERIFY(button.text().contains("31% opacity"));
|
||||
const auto picture = button.grab().toImage();
|
||||
bool pureGreen = false;
|
||||
for (int y = 0; y < picture.height(); ++y) for (int x = 0; x < picture.width(); ++x)
|
||||
pureGreen |= picture.pixelColor(x, y) == QColor(0, 255, 0);
|
||||
QVERIFY(pureGreen);
|
||||
}
|
||||
void coloursFollowActivationOrder() {
|
||||
Chart chart("°C");
|
||||
for (const auto &key : {"base", "x", "y"}) chart.addSeries(key);
|
||||
chart.setSelected("base", true); QCOMPARE(chart.color("base"), QColor("#3daee9"));
|
||||
chart.setSelected("x", true); const auto second = chart.color("x");
|
||||
chart.setSelected("y", true); const auto third = chart.color("y");
|
||||
QVERIFY(second != third); QVERIFY(second != chart.color("base"));
|
||||
QCOMPARE(chart.color("x"), second);
|
||||
chart.setSelected("x", false); chart.setSelected("y", false);
|
||||
chart.setSelected("y", true); chart.setSelected("x", true);
|
||||
QCOMPARE(chart.color("y"), second); QCOMPARE(chart.color("x"), third);
|
||||
}
|
||||
void sharedSaveAndUndo() {
|
||||
QTemporaryDir config; qputenv("XDG_CONFIG_HOME", config.path().toUtf8());
|
||||
QSettings settings("fedora-tools", "framework-laptop-tools");
|
||||
Window window; window.show();
|
||||
auto *bar = window.findChild<QWidget *>("pendingChanges"); QVERIFY(bar);
|
||||
auto *save = window.findChild<QPushButton *>("saveAll");
|
||||
auto *undo = window.findChild<QPushButton *>("undoAll");
|
||||
auto *tabs = window.findChild<QTabWidget *>();
|
||||
QTRY_VERIFY(save->isEnabled()); QVERIFY(bar->isHidden());
|
||||
auto *fast = window.findChild<QComboBox *>("fastInterval");
|
||||
auto *mode = window.findChild<QComboBox *>("trayDisplayMode");
|
||||
auto *autostart = window.findChild<QCheckBox *>("loginAutostart");
|
||||
const QString path = config.path() + "/autostart/se.ajpanton.framework-laptop-tools.desktop";
|
||||
fast->setCurrentIndex(fast->findData(4000));
|
||||
QVERIFY(!bar->isHidden()); tabs->setCurrentIndex(5); mode->setCurrentIndex(1);
|
||||
autostart->setChecked(true);
|
||||
QCOMPARE(settings.value("sampling/fast").toInt(), 1000);
|
||||
QVERIFY(!settings.contains("tray/mode")); QVERIFY(!QFile::exists(path));
|
||||
tabs->setCurrentIndex(0); QVERIFY(!bar->isHidden());
|
||||
QTRY_VERIFY(undo->isEnabled()); undo->click();
|
||||
QVERIFY(bar->isHidden()); QCOMPARE(fast->currentData().toInt(), 1000); QCOMPARE(mode->currentIndex(), 0);
|
||||
QVERIFY(!autostart->isChecked()); QVERIFY(!QFile::exists(path));
|
||||
fast->setCurrentIndex(fast->findData(2000)); mode->setCurrentIndex(2); autostart->setChecked(true);
|
||||
QTRY_VERIFY(save->isEnabled()); save->click();
|
||||
QVERIFY(bar->isHidden()); QCOMPARE(settings.value("sampling/fast").toInt(), 2000);
|
||||
QCOMPARE(settings.value("tray/mode").toString(), QString("number")); QVERIFY(QFile::exists(path));
|
||||
fast->setCurrentIndex(fast->findData(500)); mode->setCurrentIndex(1);
|
||||
QTRY_VERIFY(undo->isEnabled()); undo->click();
|
||||
QCOMPARE(fast->currentData().toInt(), 2000); QCOMPARE(mode->currentIndex(), 2); QVERIFY(bar->isHidden());
|
||||
// Hover is a timestamp shared by every graph, not the same screen coordinate.
|
||||
const auto charts = window.findChildren<Chart *>(); QCOMPARE(charts.size(), 4);
|
||||
const qint64 time = QDateTime::currentMSecsSinceEpoch() - 1000;
|
||||
charts.first()->hovered(time);
|
||||
for (auto *chart : charts) QCOMPARE(chart->hoverTime(), std::optional<qint64>(time));
|
||||
charts.first()->hovered(-1);
|
||||
for (auto *chart : charts) QVERIFY(!chart->hoverTime());
|
||||
}
|
||||
void lowFanConfirmationCanBeCancelled() {
|
||||
QTemporaryDir config; qputenv("XDG_CONFIG_HOME", config.path().toUtf8());
|
||||
Window window; window.show();
|
||||
auto *save = window.findChild<QPushButton *>("saveAll");
|
||||
QTRY_VERIFY(save->isEnabled());
|
||||
auto *mode = window.findChild<QComboBox *>("fanMode");
|
||||
auto *duty = window.findChild<ValueControl *>("fanDuty");
|
||||
mode->setCurrentIndex(1); duty->setValue(0);
|
||||
QVERIFY(!window.findChild<QLabel *>("lowFanWarning")->isHidden());
|
||||
bool sawConfirmation = false;
|
||||
QTimer::singleShot(0, &window, [&] {
|
||||
auto *dialog = qobject_cast<QMessageBox *>(QApplication::activeModalWidget());
|
||||
if (dialog) { sawConfirmation = dialog->text().contains("0%"); dialog->done(QMessageBox::Cancel); }
|
||||
});
|
||||
save->click(); QVERIFY(sawConfirmation);
|
||||
QVERIFY(!window.findChild<QWidget *>("pendingChanges")->isHidden());
|
||||
QVERIFY(save->isEnabled()); // No authorisation or hardware operation started.
|
||||
window.findChild<QPushButton *>("undoAll")->click();
|
||||
QVERIFY(window.findChild<QWidget *>("pendingChanges")->isHidden());
|
||||
}
|
||||
};
|
||||
QTEST_MAIN(WindowTest)
|
||||
#include "test-window.moc"
|
||||
Reference in New Issue
Block a user