Add Framework laptop controls and monitoring with hardware-aware discovery

This commit is contained in:
ajp_anton
2026-09-11 10:29:27 +00:00
parent 787231cb03
commit 8719402d97
50 changed files with 3611 additions and 9 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
MIT License
MIT Licence
Copyright (c) 2026 fedora-tools contributors
+15 -2
View File
@@ -36,7 +36,7 @@ systems, architectures, or distributions may be limited.
The optional `fedora-tools-settings` package adds a **Fedora Tools** page to
Plasma System Settings. It discovers tools from this RPM repository, shows
which are installed, and can install available tools after administrator
authorization. Installed tools can also be removed, and supported tools expose
authorisation. Installed tools can also be removed, and supported tools expose
their settings there. Individual tools do not require the settings module.
### Touchpad hold-tap
@@ -65,7 +65,7 @@ display is turned off or the system resumes from sleep.
The package applies a narrow patch to Plasma 6.7's lock-screen QML and reapplies
it after `plasma-desktop` updates. An unknown future layout is left unchanged
instead of being modified speculatively. Removing the package restores Plasma's
original behavior. It retries lock-screen authentication after resume and the
original behaviour. It retries lock-screen authentication after resume and the
automatic-lock grace period, but does not modify `kscreenlocker` authentication
code or `fprintd`.
@@ -132,3 +132,16 @@ and can also launch applications or custom commands. Add
**Panel Actions Spacer** to reserve flexible space with a configurable minimum
width. Open **Panel Actions** from the application menu or Fedora Tools settings,
with or without a spacer. See its [README](plasma-panel-actions/README.md).
### Framework Laptop Tools
Experimental `framework-laptop-tools` provides a desktop window and tray icon
for Framework Laptop 13 Pro (Intel Core Ultra Series 3). Monitor selectable
frequency and temperature graphs, fan speed, and battery charge and power.
Controls cover lighting, fan modes, battery charging, and CPU frequency limits.
Installing or opening it does not change hardware settings. See its
[README](framework-laptop-tools/README.md) for supported interfaces and limits,
including the need to use Fn+Space to switch keyboard Auto mode.
Fedora Tools settings hides this download on other hardware. If manually
installed, it remains listed as incompatible and can still be removed.
+6 -1
View File
@@ -16,7 +16,7 @@ package names that independently resolve to that capability in the
Installed tools can also be configured or removed from the module. Package
operations run one at a time; other actions remain disabled until DNF finishes.
The module currently configures task-group shortcut behavior, touchpad hold-tap
The module currently configures task-group shortcut behaviour, touchpad hold-tap
timing and output, and the experimental fingerprint workaround.
Installed packages are discovered independently of repository metadata, so
@@ -33,6 +33,11 @@ shell or administrator privileges. This registration needs no tool-specific
change to the settings module. The package must provide `fedora-tools-tool`;
arbitrary installed applications are not included in this list.
Hardware-specific compatibility checks hide Framework Laptop Tools from the
available list on unvalidated models. A manually installed copy remains
visible as incompatible, with removal available but configuration and updates
disabled. Its own privileged helper also checks the model before any change.
Installing this module does not install or activate any other tool. Tools
remain usable without the module.
@@ -1,6 +1,6 @@
Name: fedora-tools-settings
Version: 0.1.0
Release: 11%{?dist}
Release: 12%{?dist}
Summary: Plasma System Settings module for Fedora Tools
License: MIT
@@ -29,7 +29,7 @@ Requires: rpm
%description
Adds a Fedora Tools page to KDE Plasma System Settings. It discovers tools from
the configured RPM repository, reports installation state, and can install
tools after administrator authorization.
tools after administrator authorisation.
%prep
%autosetup
@@ -74,6 +74,9 @@ done
%{_datadir}/polkit-1/actions/se.ajpanton.fedoratools.policy
%changelog
* Tue Sep 08 2026 fedora-tools contributors - 0.1.0-12
- Hide unsupported Framework hardware tools and flag incompatible local installs
* Mon Sep 07 2026 fedora-tools contributors - 0.1.0-11
- Invalidate cached QML whenever the embedded settings pages change
+6
View File
@@ -125,6 +125,12 @@ public Q_SLOTS:
KAuth::ActionReply installpackage(const QVariantMap &arguments)
{
const QString packageName = arguments.value(QStringLiteral("packageName")).toString();
const QString incompatible = packageCompatibilityError(packageName);
if (!incompatible.isEmpty()) {
KAuth::ActionReply reply = KAuth::ActionReply::HelperErrorReply();
reply.addData(QStringLiteral("message"), incompatible);
return reply;
}
if (!isValidPackageName(packageName) || !isPublishedTool(packageName)) {
KAuth::ActionReply reply = KAuth::ActionReply::HelperErrorReply();
reply.addData(QStringLiteral("message"), QStringLiteral("The requested package is not a published Fedora tool."));
@@ -3,6 +3,7 @@
#include "packageutils.h"
#include <QRegularExpression>
#include <QFile>
#include <utility>
@@ -38,3 +39,15 @@ bool isValidPackageName(const QString &name)
static const QRegularExpression expression(QStringLiteral("^[a-z0-9][a-z0-9+._-]*$"));
return expression.match(name).hasMatch();
}
QString packageCompatibilityError(const QString &name, const QString &sys)
{
if (name != QLatin1String("framework-laptop-tools")) return {};
auto read = [](const QString &path) {
QFile file(path);
return file.open(QIODevice::ReadOnly) ? QString::fromUtf8(file.readAll()).trimmed() : QString();
};
if (read(sys + QStringLiteral("/class/dmi/id/sys_vendor")) == QLatin1String("Framework")
&& read(sys + QStringLiteral("/class/dmi/id/product_name")) == QLatin1String("Laptop 13 Pro (Intel Core Ultra Series 3)")) return {};
return QStringLiteral("Incompatible: requires Framework Laptop 13 Pro (Intel Core Ultra Series 3).");
}
+1
View File
@@ -16,3 +16,4 @@ struct PackageRecord
QList<PackageRecord> parsePackageRecords(const QByteArray &output);
bool isValidPackageName(const QString &name);
QString packageCompatibilityError(const QString &name, const QString &sys = QStringLiteral("/sys"));
+10 -3
View File
@@ -87,13 +87,15 @@ QVariant ToolModel::data(const QModelIndex &index, int role) const
case UpdateAvailableRole:
return tool.updateAvailable;
case ConfigurableRole:
return tool.installed
return tool.installed && tool.compatibilityError.isEmpty()
&& (!tool.configurationCommand.isEmpty()
|| tool.packageName == QStringLiteral("plasma-fingerprint-workaround")
|| tool.packageName == QStringLiteral("plasma-task-group-shortcuts")
|| tool.packageName == QStringLiteral("touchpad-hold-tap"));
case ExternalConfigurationRole:
return tool.installed && !tool.configurationCommand.isEmpty();
case CompatibilityErrorRole:
return tool.compatibilityError;
default:
return {};
}
@@ -112,6 +114,7 @@ QHash<int, QByteArray> ToolModel::roleNames() const
{UpdateAvailableRole, "updateAvailable"},
{ConfigurableRole, "configurable"},
{ExternalConfigurationRole, "externalConfiguration"},
{CompatibilityErrorRole, "compatibilityError"},
};
}
@@ -129,9 +132,12 @@ void ToolModel::setPackages(const QList<PackageRecord> &installed,
tool.summary = package.summary;
tool.installed = true;
tool.configurationCommand = readConfigurationCommand(package.name);
tool.compatibilityError = packageCompatibilityError(package.name);
}
for (const PackageRecord &package : available) {
const QString incompatible = packageCompatibilityError(package.name);
if (!incompatible.isEmpty() && !tools.contains(package.name)) continue;
Tool &tool = tools[package.name];
tool.packageName = package.name;
tool.availableVersion = package.version;
@@ -139,8 +145,9 @@ void ToolModel::setPackages(const QList<PackageRecord> &installed,
if (!tool.installed) {
tool.summary = package.summary;
}
tool.available = true;
tool.updateAvailable = tool.installed && updates.contains(package.name);
tool.compatibilityError = incompatible;
tool.available = incompatible.isEmpty();
tool.updateAvailable = tool.available && tool.installed && updates.contains(package.name);
}
QList<Tool> merged = tools.values();
+2
View File
@@ -16,6 +16,7 @@ struct Tool
QString architecture;
QString summary;
QStringList configurationCommand;
QString compatibilityError;
bool installed = false;
bool available = false;
bool updateAvailable = false;
@@ -37,6 +38,7 @@ public:
UpdateAvailableRole,
ConfigurableRole,
ExternalConfigurationRole,
CompatibilityErrorRole,
};
explicit ToolModel(QObject *parent = nullptr);
+4
View File
@@ -87,6 +87,7 @@ KCM.SimpleKCM {
required property bool updateAvailable
required property bool configurable
required property bool externalConfiguration
required property string compatibilityError
width: toolList.width
@@ -121,6 +122,9 @@ KCM.SimpleKCM {
Controls.Label {
Layout.fillWidth: true
text: {
if (toolCard.compatibilityError.length > 0) {
return toolCard.compatibilityError
}
if (toolCard.updateAvailable) {
return i18n("Installed %1; %2 is available", toolCard.version, toolCard.availableVersion)
}
@@ -3,12 +3,28 @@
#include "packageutils.h"
#include <QTest>
#include <QTemporaryDir>
#include <QDir>
#include <QFile>
class PackageUtilsTest : public QObject
{
Q_OBJECT
private Q_SLOTS:
void frameworkCompatibility() {
QTemporaryDir root;
QVERIFY(packageCompatibilityError(QStringLiteral("touchpad-hold-tap"), root.path()).isEmpty());
QVERIFY(!packageCompatibilityError(QStringLiteral("framework-laptop-tools"), root.path()).isEmpty());
QVERIFY(QDir().mkpath(root.path() + QStringLiteral("/class/dmi/id")));
QFile vendor(root.path() + QStringLiteral("/class/dmi/id/sys_vendor"));
QVERIFY(vendor.open(QIODevice::WriteOnly)); vendor.write("Framework\n"); vendor.close();
QFile product(root.path() + QStringLiteral("/class/dmi/id/product_name"));
QVERIFY(product.open(QIODevice::WriteOnly)); product.write("Laptop 13 Pro (Intel Core Ultra Series 3)\n"); product.close();
QVERIFY(packageCompatibilityError(QStringLiteral("framework-laptop-tools"), root.path()).isEmpty());
QVERIFY(product.open(QIODevice::WriteOnly | QIODevice::Truncate)); product.write("Laptop 13\n"); product.close();
QVERIFY(!packageCompatibilityError(QStringLiteral("framework-laptop-tools"), root.path()).isEmpty());
}
void parsesQueryOutput()
{
const auto records = parsePackageRecords(
@@ -12,6 +12,18 @@ class ToolModelTest : public QObject
Q_OBJECT
private Q_SLOTS:
void incompatibleFrameworkIsOnlyListedWhenInstalled() {
if (packageCompatibilityError(QStringLiteral("framework-laptop-tools")).isEmpty()) QSKIP("Requires a non-Framework test host");
const PackageRecord package{QStringLiteral("framework-laptop-tools"), QStringLiteral("1"), QStringLiteral("x86_64"), QStringLiteral("Hardware controls")};
ToolModel model; model.setPackages({}, {package});
QCOMPARE(model.rowCount(), 0);
model.setPackages({package}, {package});
QCOMPARE(model.rowCount(), 1);
QVERIFY(!model.data(model.index(0), ToolModel::CompatibilityErrorRole).toString().isEmpty());
QVERIFY(!model.mayInstall(package.name));
QVERIFY(model.mayRemove(package.name));
QVERIFY(!model.data(model.index(0), ToolModel::ConfigurableRole).toBool());
}
void discoversLocallyInstalledToolSettings()
{
QTemporaryDir directory;
+45
View File
@@ -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()
+184
View File
@@ -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
7575 W), while percentages use 0100.
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: 0100%. **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: 1100%, 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 0100%, 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: 50100%, 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
+261
View File
@@ -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"); }
}
+52
View File
@@ -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());
}
+16
View File
@@ -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;
};
+163
View File
@@ -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());
}
+15
View File
@@ -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);
+163
View File
@@ -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);
}
+42
View File
@@ -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;
};
+67
View File
@@ -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"
+98
View File
@@ -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;
}
+10
View File
@@ -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);
+43
View File
@@ -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();
}
+306
View File
@@ -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;
}
+28
View File
@@ -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);
+129
View File
@@ -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"
+52
View File
@@ -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>&nbsp;<span style=\"text-decoration:%3\">%5</span></a>")
.arg(i).arg(text.name(), enabled ? "none" : "line-through", dot.name(), sensor.name.toHtmlEscaped().replace(' ', "&nbsp;")));
}
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(' ', "&nbsp;")));
setText(links.join(" &nbsp; &nbsp; "));
}
void Legend::changeEvent(QEvent *event)
{
QLabel::changeEvent(event);
if (event->type() == QEvent::PaletteChange || event->type() == QEvent::ApplicationPaletteChange) refresh();
}
+24
View File
@@ -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;
};
+35
View File
@@ -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"
+79
View File
@@ -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);
}
+22
View File
@@ -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);
+145
View File
@@ -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;
}
+36
View File
@@ -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()));
}
+26
View File
@@ -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;
};
+548
View File
@@ -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();
}
+86
View File
@@ -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"
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: MIT
set -euo pipefail
repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
topdir="$repo_root/rpmbuild"
mkdir -p "$topdir"/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS}
tar -C "$repo_root" --transform='s,^framework-laptop-tools,framework-laptop-tools-0.1.0,' \
-czf "$topdir/SOURCES/framework-laptop-tools-0.1.0.tar.gz" \
framework-laptop-tools/CMakeLists.txt framework-laptop-tools/src \
framework-laptop-tools/data framework-laptop-tools/tests framework-laptop-tools/README.md
install -m 0644 "$repo_root/LICENSE" "$topdir/SOURCES/LICENSE"
rpmbuild --define "_topdir $topdir" -bb "$repo_root/framework-laptop-tools/framework-laptop-tools.spec"