From 8719402d97027a56cb87444362ec6aa1e4d868a3 Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Fri, 11 Sep 2026 10:29:27 +0000 Subject: [PATCH] Add Framework laptop controls and monitoring with hardware-aware discovery --- LICENSE | 2 +- README.md | 17 +- fedora-tools-settings/README.md | 7 +- .../fedora-tools-settings.spec | 7 +- fedora-tools-settings/src/helper.cpp | 6 + fedora-tools-settings/src/packageutils.cpp | 13 + fedora-tools-settings/src/packageutils.h | 1 + fedora-tools-settings/src/toolmodel.cpp | 13 +- fedora-tools-settings/src/toolmodel.h | 2 + fedora-tools-settings/src/ui/main.qml | 4 + .../tests/test-packageutils.cpp | 16 + .../tests/test-toolmodel.cpp | 12 + framework-laptop-tools/CMakeLists.txt | 45 ++ framework-laptop-tools/README.md | 184 ++++++ .../data/framework-laptop-tools-cpu.service | 18 + .../framework-laptop-tools-fan-resume.service | 13 + .../data/framework-laptop-tools-fan.service | 20 + .../data/framework-laptop-tools.json | 1 + ...se.ajpanton.framework-laptop-tools.desktop | 7 + .../data/se.ajpanton.frameworktools.actions | 13 + .../framework-laptop-tools.spec | 89 +++ framework-laptop-tools/src/chart.cpp | 261 +++++++++ framework-laptop-tools/src/chart.h | 52 ++ framework-laptop-tools/src/colorbutton.cpp | 27 + framework-laptop-tools/src/colorbutton.h | 16 + framework-laptop-tools/src/cpu.cpp | 163 ++++++ framework-laptop-tools/src/cpu.h | 15 + framework-laptop-tools/src/cpupage.cpp | 163 ++++++ framework-laptop-tools/src/cpupage.h | 42 ++ framework-laptop-tools/src/cpuservice.cpp | 67 +++ framework-laptop-tools/src/fan.cpp | 98 ++++ framework-laptop-tools/src/fan.h | 10 + framework-laptop-tools/src/fanservice.cpp | 43 ++ framework-laptop-tools/src/hardware.cpp | 306 ++++++++++ framework-laptop-tools/src/hardware.h | 28 + framework-laptop-tools/src/helper.cpp | 129 +++++ framework-laptop-tools/src/legend.cpp | 52 ++ framework-laptop-tools/src/legend.h | 24 + framework-laptop-tools/src/main.cpp | 35 ++ framework-laptop-tools/src/tray.cpp | 79 +++ framework-laptop-tools/src/tray.h | 22 + framework-laptop-tools/src/traypage.cpp | 145 +++++ framework-laptop-tools/src/traypage.h | 36 ++ framework-laptop-tools/src/valuecontrol.cpp | 42 ++ framework-laptop-tools/src/valuecontrol.h | 26 + framework-laptop-tools/src/window.cpp | 548 ++++++++++++++++++ framework-laptop-tools/src/window.h | 86 +++ .../tests/test-hardware.cpp | 242 ++++++++ framework-laptop-tools/tests/test-window.cpp | 361 ++++++++++++ scripts/build-framework-laptop-tools-rpm | 12 + 50 files changed, 3611 insertions(+), 9 deletions(-) create mode 100644 framework-laptop-tools/CMakeLists.txt create mode 100644 framework-laptop-tools/README.md create mode 100644 framework-laptop-tools/data/framework-laptop-tools-cpu.service create mode 100644 framework-laptop-tools/data/framework-laptop-tools-fan-resume.service create mode 100644 framework-laptop-tools/data/framework-laptop-tools-fan.service create mode 100644 framework-laptop-tools/data/framework-laptop-tools.json create mode 100644 framework-laptop-tools/data/se.ajpanton.framework-laptop-tools.desktop create mode 100644 framework-laptop-tools/data/se.ajpanton.frameworktools.actions create mode 100644 framework-laptop-tools/framework-laptop-tools.spec create mode 100644 framework-laptop-tools/src/chart.cpp create mode 100644 framework-laptop-tools/src/chart.h create mode 100644 framework-laptop-tools/src/colorbutton.cpp create mode 100644 framework-laptop-tools/src/colorbutton.h create mode 100644 framework-laptop-tools/src/cpu.cpp create mode 100644 framework-laptop-tools/src/cpu.h create mode 100644 framework-laptop-tools/src/cpupage.cpp create mode 100644 framework-laptop-tools/src/cpupage.h create mode 100644 framework-laptop-tools/src/cpuservice.cpp create mode 100644 framework-laptop-tools/src/fan.cpp create mode 100644 framework-laptop-tools/src/fan.h create mode 100644 framework-laptop-tools/src/fanservice.cpp create mode 100644 framework-laptop-tools/src/hardware.cpp create mode 100644 framework-laptop-tools/src/hardware.h create mode 100644 framework-laptop-tools/src/helper.cpp create mode 100644 framework-laptop-tools/src/legend.cpp create mode 100644 framework-laptop-tools/src/legend.h create mode 100644 framework-laptop-tools/src/main.cpp create mode 100644 framework-laptop-tools/src/tray.cpp create mode 100644 framework-laptop-tools/src/tray.h create mode 100644 framework-laptop-tools/src/traypage.cpp create mode 100644 framework-laptop-tools/src/traypage.h create mode 100644 framework-laptop-tools/src/valuecontrol.cpp create mode 100644 framework-laptop-tools/src/valuecontrol.h create mode 100644 framework-laptop-tools/src/window.cpp create mode 100644 framework-laptop-tools/src/window.h create mode 100644 framework-laptop-tools/tests/test-hardware.cpp create mode 100644 framework-laptop-tools/tests/test-window.cpp create mode 100755 scripts/build-framework-laptop-tools-rpm diff --git a/LICENSE b/LICENSE index 529bc45..a5fd28a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -MIT License +MIT Licence Copyright (c) 2026 fedora-tools contributors diff --git a/README.md b/README.md index 0fcf4bf..27bdba2 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/fedora-tools-settings/README.md b/fedora-tools-settings/README.md index eb56747..319bedf 100644 --- a/fedora-tools-settings/README.md +++ b/fedora-tools-settings/README.md @@ -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. diff --git a/fedora-tools-settings/fedora-tools-settings.spec b/fedora-tools-settings/fedora-tools-settings.spec index 55b8cc9..ca7b566 100644 --- a/fedora-tools-settings/fedora-tools-settings.spec +++ b/fedora-tools-settings/fedora-tools-settings.spec @@ -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 diff --git a/fedora-tools-settings/src/helper.cpp b/fedora-tools-settings/src/helper.cpp index 04b33d4..4d2af2b 100644 --- a/fedora-tools-settings/src/helper.cpp +++ b/fedora-tools-settings/src/helper.cpp @@ -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.")); diff --git a/fedora-tools-settings/src/packageutils.cpp b/fedora-tools-settings/src/packageutils.cpp index 6b3cdf1..8735562 100644 --- a/fedora-tools-settings/src/packageutils.cpp +++ b/fedora-tools-settings/src/packageutils.cpp @@ -3,6 +3,7 @@ #include "packageutils.h" #include +#include #include @@ -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)."); +} diff --git a/fedora-tools-settings/src/packageutils.h b/fedora-tools-settings/src/packageutils.h index c969754..995efe8 100644 --- a/fedora-tools-settings/src/packageutils.h +++ b/fedora-tools-settings/src/packageutils.h @@ -16,3 +16,4 @@ struct PackageRecord QList parsePackageRecords(const QByteArray &output); bool isValidPackageName(const QString &name); +QString packageCompatibilityError(const QString &name, const QString &sys = QStringLiteral("/sys")); diff --git a/fedora-tools-settings/src/toolmodel.cpp b/fedora-tools-settings/src/toolmodel.cpp index 62f3792..113cd20 100644 --- a/fedora-tools-settings/src/toolmodel.cpp +++ b/fedora-tools-settings/src/toolmodel.cpp @@ -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 ToolModel::roleNames() const {UpdateAvailableRole, "updateAvailable"}, {ConfigurableRole, "configurable"}, {ExternalConfigurationRole, "externalConfiguration"}, + {CompatibilityErrorRole, "compatibilityError"}, }; } @@ -129,9 +132,12 @@ void ToolModel::setPackages(const QList &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 &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 merged = tools.values(); diff --git a/fedora-tools-settings/src/toolmodel.h b/fedora-tools-settings/src/toolmodel.h index 501b24f..9cfc523 100644 --- a/fedora-tools-settings/src/toolmodel.h +++ b/fedora-tools-settings/src/toolmodel.h @@ -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); diff --git a/fedora-tools-settings/src/ui/main.qml b/fedora-tools-settings/src/ui/main.qml index 950fd01..e66fe8d 100644 --- a/fedora-tools-settings/src/ui/main.qml +++ b/fedora-tools-settings/src/ui/main.qml @@ -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) } diff --git a/fedora-tools-settings/tests/test-packageutils.cpp b/fedora-tools-settings/tests/test-packageutils.cpp index eea4615..f77697a 100644 --- a/fedora-tools-settings/tests/test-packageutils.cpp +++ b/fedora-tools-settings/tests/test-packageutils.cpp @@ -3,12 +3,28 @@ #include "packageutils.h" #include +#include +#include +#include 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( diff --git a/fedora-tools-settings/tests/test-toolmodel.cpp b/fedora-tools-settings/tests/test-toolmodel.cpp index aeb89e2..6a927da 100644 --- a/fedora-tools-settings/tests/test-toolmodel.cpp +++ b/fedora-tools-settings/tests/test-toolmodel.cpp @@ -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; diff --git a/framework-laptop-tools/CMakeLists.txt b/framework-laptop-tools/CMakeLists.txt new file mode 100644 index 0000000..17745d6 --- /dev/null +++ b/framework-laptop-tools/CMakeLists.txt @@ -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() diff --git a/framework-laptop-tools/README.md b/framework-laptop-tools/README.md new file mode 100644 index 0000000..1bfc55f --- /dev/null +++ b/framework-laptop-tools/README.md @@ -0,0 +1,184 @@ +# Framework Laptop Tools + +Experimental native desktop/tray controls for **Framework Laptop 13 Pro +(Intel Core Ultra Series 3)**. Other Framework models are not yet validated. +Install `framework-laptop-tools`, then open **Framework Laptop Tools** from the +application menu or Fedora Tools settings. Installation changes no hardware +settings. Hardware changes require administrator authorisation. + +The window has Monitor, Lighting, Cooling, Battery, CPU, Tray icon and Preferences +tabs. +Bounded hardware settings use sliders with numeric fields for precise entry. +All settings tabs share a **Save and Apply / Undo changes** bar. Editing stages +changes without writing hardware, tray settings, sampling intervals or autostart. +The bar stays visible across tabs. Save applies all pending settings with one +administrator authorisation for hardware changes; Undo discards all unsaved edits. +Successful saves become the new Undo baseline. If a hardware operation fails, +earlier operations may already have applied; remaining edits stay pending. +CPU slider bounds are read from Linux's hardware-frequency limits. + +The Monitor page has CPU/GPU frequency, fan RPM, temperature and battery graphs. +The battery heading shows full capacity, health and cycle count when available. +Capacity calculated from charge capacity and nominal voltage is approximate. +Temperature labels distinguish CPU-area/board sensors from die readings; +raw sysfs paths remain available in tooltips. CPU frequency is Linux's reported +average across CPU policies, not an instantaneous measurement of every core. +GPU GT domains are shown separately rather than assuming they are identical. +Click a coloured legend to toggle its series. Hidden series remain in the legend, +greyed out and crossed out. Main temperature sensors are shown first; additional +sensors are in a collapsed section that remembers their selections, not its open +state. NVMe composite is the drive's overall reported temperature; numbered NVMe +sensors are device-specific. Memory (SPD) measures the memory module, while EC +area sensors are separate from processor/module readings. +Each graph starts with blue. Further colours maximise their minimum OKLab distance +from enabled colours within a readable candidate palette. Enabling a line assigns +its colour; existing enabled lines keep theirs. Disabled colours are released. + +Axes use round ticks and relative ages, with units below the vertical axes. +Hover draws a guide at the same timestamp on every graph, even when their +time spans differ. The tooltip still describes only the graph under the pointer. +Hover for timestamps and the nearest +available readings; no readings are invented across gaps or sleep. Battery charge +uses the left percentage axis and rate the right watts axis (positive charging, +negative discharging). Blue-grey bands mark logind-observed sleep; faint red bands +mark observed charger connection. Plug changes during sleep are unknown, so AC +bands are not extended through sleep. There is no history from before app startup. +History is bounded to 600 samples per series and kept only while the app runs. + +Sampling choices are 0.5/1/2/4 seconds for frequencies and temperatures, and +15/30/60/120 seconds for the battery graph. Tray autostart is optional. +Closing the window leaves monitoring in the tray; Quit exits the application. +The Tray icon tab selects a normal icon, a history graph, or a number. Sources +include CPU usage, CPU/GPU frequency, temperatures, charge level and battery rate. +Sensor graphs share Monitor's history, including gaps, regardless of which +sensor is selected for the tray. CPU usage also retains 600 samples. Switching +readings or resuming from sleep does not clear history. Frequency numbers use +GHz; the hover tooltip includes the full reading and unit. The tray has optional +borders, a transparent or coloured background, line and fill colours, and optional +area fill (including adjustable opacity). Frequency ceilings and temperature +ranges are saved per sensor; battery rate also has adjustable bounds (initially +−75–75 W), while percentages use 0–100. +Outside readings can follow the inner edge, optionally in a different colour, +or be hidden. The line remains inside the border when a border is enabled. +Colour buttons show the opaque RGB swatch and label opacity separately, so a +translucent fill is not mistaken for a darker RGB colour. +Plasma applies its own hover highlight to tray icons; the app does not patch +the system tray to suppress that effect. + +## Controls and limits + +- Keyboard brightness: 0–100%. **Use Fn+Space to leave or enter Auto mode.** + The inspected 13 Pro firmware does not expose a host command to change that + mode; its ambient-light logic overrides manual percentage changes in Auto. +- Power-button brightness: 1–100%, or firmware Auto. This is the illuminated + power button surrounding the fingerprint reader, not its authentication. + The Automatic brightness checkbox disables the slider; both mode and brightness + are staged until Save and Apply. Unchecking it allows a fixed brightness to be selected. +- Fan: firmware Auto, manual duty, or a four-point curve. Manual values + range from 0–100%, including fan off. Manual speeds below 30% show a warning. + Saving manual or curve speeds below 30% also requires explicit confirmation. + Four editable temperatures must increase between 20 and 85 °C, reaching 100% + duty at the final point. Speeds between points are interpolated. The hottest EC sensor + drives the curve. Speed increases immediately and decreases gradually. + The privileged worker returns to Auto on suspend, reboot, a sensor fault, + high temperatures, watchdog timeout or service exit. It does not change + thermal warning/shutdown thresholds. Overrides remain active with the GUI closed. + Saving an override writes `/etc/framework-laptop-tools/fan.json` and enables + the worker at boot and after sleep. Sleep still stops the worker and restores + Auto first. Failure restores Auto without a restart loop; + saved settings are retried at the next boot/resume or explicit Save and Apply. + Legacy temporary overrides are still recognised until stopped, but newly saved + overrides always persist; installation itself does not enable them. + Selecting firmware Auto removes saved settings and disables automatic startup. + Do not combine with another fan controller. +- Battery charge limit: 50–100%, stored by the firmware. Charge-power settings + convert watts to a current limit using present battery voltage; actual watts + vary with voltage and system/charger limits. Zero restores firmware defaults. + This is **battery charging power**, not wall power or total laptop power. + Charge current has no read-back command in the interface used here. + The C-rate used by some other utilities expresses current relative to battery + capacity: 1 C means 4.64 A for a 4.64 Ah battery, not a fixed number of watts. +- CPU: minimum/maximum frequency, governor and energy preference, with optional + separate battery/AC profiles. Frequency bounds are clamped per policy: a + requested 4.5 GHz maximum does not restrict a P-core to a slower core's + 3.3 GHz ceiling. These are bounds, not guaranteed clock speeds. + On Intel hybrid systems, kernel `cpu_core`/`cpu_atom` PMU membership identifies + P-core and E-core policy groups, each with independent overrides. E-core limits + clamp to each E-core's own ceiling, including lower-power E-cores. If the kernel + cannot identify the groups unambiguously, a single shared range is offered. + +Lighting and battery readings refresh on entry and every two seconds while their +tab is visible. Unsaved edits are protected. Undo restores the unedited snapshot; +normal live refresh then resumes. Charge-current limits cannot be read back, so +their initial draft is firmware default, not a claim about current hardware state. +Graph legend toggles remain immediate viewing controls, separate from staged +settings tabs. + +Battery time remaining/full uses UPower when available. Time to a custom charge +limit is approximate, based on present current; charging taper makes it less +accurate near full. No estimate is displayed when the needed readings are absent. + +## Saved CPU profiles + +CPU editing is staged. Splitting copies the shared values into both columns; +battery is on the left and AC on the right. Joining uses the battery column. +The hidden AC draft remains recoverable by splitting again until **Save and +apply** commits the joined profile. Shared configurations store only one copy. +**Undo changes** restores the last saved configuration. Background +firmware refreshes do not overwrite a dirty CPU draft. + +Frequency bounds, governor and energy preference are independent. Frequency +overriding is unchecked by default, and both dropdowns default to **auto**. +Auto means this tool never writes that attribute; it is not a preset or reset. +Saving all-Auto profiles with frequency overriding off stops/disables the CPU +service. Existing CPU limits are left unchanged, including when giving up a +previous override, switching to an Auto power-source profile, or uninstalling. +There is no TuneD reload or blanket restoration that could disturb another +controller. Avoid assigning the same setting to multiple controllers. + +The top of the CPU page always shows live Linux values, independently of the +draft below. The frequency sliders are disabled when their override is unchecked; +governor and EPP remain independently selectable. Tooltip explanations replace +the longer instructions formerly at the bottom of the page. + +Explicit overrides apply at boot, resume, power-source changes and successful +TuneD profile changes after two seconds for the transition to settle. No TuneD +dependency is required. The service works with the GUI closed; configuration is +root-owned at `/etc/framework-laptop-tools/cpu.json`. Earlier all-or-nothing configurations +are converted to independent overrides while preserving whether they were enabled. + +With active Intel HWP, the Performance governor forces performance EPP and rejects +other EPP values. For an explicitly selected Performance governor, this tool uses +Powersave when the selected EPP (or the live EPP when Auto) is incompatible. The +dropdown retains the user's choice and displays a warning. With EPP on Auto, the +service observes EPP changes and reevaluates that explicit governor choice. + +If governor is Auto and the live Performance governor blocks an explicitly chosen +non-performance EPP, applying fails with a clear message: choose Powersave or leave +EPP on Auto. It does not silently take over the Auto governor. Explicit governor +changes can themselves affect EPP inside the kernel; Auto does not undo those +kernel side effects. Linux does not provide independent control of every combination. + +Thermal, power and boost limits still apply. Powersave on active Intel HWP allows +dynamic clocks and boost; it does not mean locking the CPU at minimum frequency. + +## Interfaces + +No firmware patch, raw port I/O, downloaded executable, or third-party service +is used. Monitoring reads Linux sysfs and UPower. A restricted KAuth helper +uses sysfs and `/dev/cros_ec` for documented EC commands. Model checks are +repeated in the privileged helper, not just in the GUI. + +References: + +- [Framework's hardware library and CLI](https://github.com/FrameworkComputer/framework-system) +- [13 Pro keyboard firmware](https://github.com/FrameworkComputer/EmbeddedController/blob/fwk-sakura-20260429/zephyr/program/framework/src/keyboard_customization_13.c) +- [Framework LED commands](https://github.com/FrameworkComputer/EmbeddedController/blob/fwk-sakura-20260429/zephyr/program/framework/src/led.c) +- [Linux EC hardware monitor](https://www.kernel.org/doc/html/latest/hwmon/cros_ec_hwmon.html) +- [Linux CPU frequency controls](https://www.kernel.org/doc/html/latest/admin-guide/pm/cpufreq.html) +- [Intel governor and EPP behaviour](https://www.kernel.org/doc/html/latest/admin-guide/pm/intel_pstate.html) +- [TuneD profile documentation](https://tuned-project.org/docs/manual.html) +- [Linux battery units](https://www.kernel.org/doc/html/latest/power/power_supply_class.html) +- [Framework Control](https://github.com/ozturkkl/framework-control) and + [framework-tool-tui](https://github.com/grouzen/framework-tool-tui) provide + useful interface references; their code is not bundled here. diff --git a/framework-laptop-tools/data/framework-laptop-tools-cpu.service b/framework-laptop-tools/data/framework-laptop-tools-cpu.service new file mode 100644 index 0000000..640abb5 --- /dev/null +++ b/framework-laptop-tools/data/framework-laptop-tools-cpu.service @@ -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 diff --git a/framework-laptop-tools/data/framework-laptop-tools-fan-resume.service b/framework-laptop-tools/data/framework-laptop-tools-fan-resume.service new file mode 100644 index 0000000..c7edb6c --- /dev/null +++ b/framework-laptop-tools/data/framework-laptop-tools-fan-resume.service @@ -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 diff --git a/framework-laptop-tools/data/framework-laptop-tools-fan.service b/framework-laptop-tools/data/framework-laptop-tools-fan.service new file mode 100644 index 0000000..72aebe0 --- /dev/null +++ b/framework-laptop-tools/data/framework-laptop-tools-fan.service @@ -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 diff --git a/framework-laptop-tools/data/framework-laptop-tools.json b/framework-laptop-tools/data/framework-laptop-tools.json new file mode 100644 index 0000000..41238ed --- /dev/null +++ b/framework-laptop-tools/data/framework-laptop-tools.json @@ -0,0 +1 @@ +{"command": ["/usr/bin/framework-laptop-tools"]} diff --git a/framework-laptop-tools/data/se.ajpanton.framework-laptop-tools.desktop b/framework-laptop-tools/data/se.ajpanton.framework-laptop-tools.desktop new file mode 100644 index 0000000..e09f87b --- /dev/null +++ b/framework-laptop-tools/data/se.ajpanton.framework-laptop-tools.desktop @@ -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; diff --git a/framework-laptop-tools/data/se.ajpanton.frameworktools.actions b/framework-laptop-tools/data/se.ajpanton.frameworktools.actions new file mode 100644 index 0000000..6ea82dd --- /dev/null +++ b/framework-laptop-tools/data/se.ajpanton.frameworktools.actions @@ -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 diff --git a/framework-laptop-tools/framework-laptop-tools.spec b/framework-laptop-tools/framework-laptop-tools.spec new file mode 100644 index 0000000..0ab86ca --- /dev/null +++ b/framework-laptop-tools/framework-laptop-tools.spec @@ -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 diff --git a/framework-laptop-tools/src/chart.cpp b/framework-laptop-tools/src/chart.cpp new file mode 100644 index 0000000..8d27e9f --- /dev/null +++ b/framework-laptop-tools/src/chart.cpp @@ -0,0 +1,261 @@ +// SPDX-License-Identifier: MIT +#include "chart.h" +#include +#include +#include +#include +#include +#include +#include +#include + +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 &used) +{ + if (used.isEmpty()) return QColor("#3daee9"); + QList 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 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 Chart::history(const QString &id) const +{ + QVector 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 &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::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 plugged, qint64 now) +{ + // Charger state cannot be observed during suspend; don't extend AC bands through it. + const std::optional 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"); } +} diff --git a/framework-laptop-tools/src/chart.h b/framework-laptop-tools/src/chart.h new file mode 100644 index 0000000..d1422c5 --- /dev/null +++ b/framework-laptop-tools/src/chart.h @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MIT +#pragma once +#include +#include +#include +#include +#include + +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 &values, int intervalMs, + qint64 now = QDateTime::currentMSecsSinceEpoch()); + void setPowerState(bool sleeping, std::optional plugged, + qint64 now = QDateTime::currentMSecsSinceEpoch()); + QString readingAt(qint64 time) const; + QColor color(const QString &id) const; + QVector history(const QString &id) const; + void setHoverTime(qint64 time) { m_hoverTime = time < 0 ? std::nullopt : std::optional(time); update(); } + std::optional 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 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 m_series; + QSet m_selected; + QVector m_bands; + QString m_unit; + int m_interval = 1000; + qint64 m_sampleTime = QDateTime::currentMSecsSinceEpoch(); + std::optional m_hoverTime; +}; diff --git a/framework-laptop-tools/src/colorbutton.cpp b/framework-laptop-tools/src/colorbutton.cpp new file mode 100644 index 0000000..5d2b061 --- /dev/null +++ b/framework-laptop-tools/src/colorbutton.cpp @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +#include "colorbutton.h" +#include +#include + +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()); +} diff --git a/framework-laptop-tools/src/colorbutton.h b/framework-laptop-tools/src/colorbutton.h new file mode 100644 index 0000000..0b7cab6 --- /dev/null +++ b/framework-laptop-tools/src/colorbutton.h @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +#pragma once +#include + +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; +}; diff --git a/framework-laptop-tools/src/cpu.cpp b/framework-laptop-tools/src/cpu.cpp new file mode 100644 index 0000000..6c6fb1e --- /dev/null +++ b/framework-laptop-tools/src/cpu.cpp @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: MIT +#include "cpu.h" +#include +#include +#include +#include +#include +#include + +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> 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 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(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()); +} diff --git a/framework-laptop-tools/src/cpu.h b/framework-laptop-tools/src/cpu.h new file mode 100644 index 0000000..f70bc62 --- /dev/null +++ b/framework-laptop-tools/src/cpu.h @@ -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 onAcPower(const QString &sys = QStringLiteral("/sys")); +QVariantMap readCpuConfig(QString &error); diff --git a/framework-laptop-tools/src/cpupage.cpp b/framework-laptop-tools/src/cpupage.cpp new file mode 100644 index 0000000..64dc060 --- /dev/null +++ b/framework-laptop-tools/src/cpupage.cpp @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: MIT +#include "cpupage.h" +#include +#include +#include +#include + +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); +} diff --git a/framework-laptop-tools/src/cpupage.h b/framework-laptop-tools/src/cpupage.h new file mode 100644 index 0000000..ac7ceef --- /dev/null +++ b/framework-laptop-tools/src/cpupage.h @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +#pragma once +#include "cpu.h" +#include +#include +#include +#include "valuecontrol.h" +#include +#include + +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 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; +}; diff --git a/framework-laptop-tools/src/cpuservice.cpp b/framework-laptop-tools/src/cpuservice.cpp new file mode 100644 index 0000000..5898930 --- /dev/null +++ b/framework-laptop-tools/src/cpuservice.cpp @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MIT +#include "cpu.h" +#include +#include +#include +#include +#include +#include + +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 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" diff --git a/framework-laptop-tools/src/fan.cpp b/framework-laptop-tools/src/fan.cpp new file mode 100644 index 0000000..812e5fb --- /dev/null +++ b/framework-laptop-tools/src/fan.cpp @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: MIT +#include "fan.h" +#include +#include +#include +#include + +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 fanTemperatures(const QVariantMap &config) +{ + if (!config.contains("temperatures")) return {40, 55, 70, 85}; + QVector 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(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 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(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; +} diff --git a/framework-laptop-tools/src/fan.h b/framework-laptop-tools/src/fan.h new file mode 100644 index 0000000..eb0bd98 --- /dev/null +++ b/framework-laptop-tools/src/fan.h @@ -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 fanTemperatures(const QVariantMap &config); diff --git a/framework-laptop-tools/src/fanservice.cpp b/framework-laptop-tools/src/fanservice.cpp new file mode 100644 index 0000000..87756c4 --- /dev/null +++ b/framework-laptop-tools/src/fanservice.cpp @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +#include "fan.h" +#include +#include +#include +#include +#include +#include +#include + +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(); +} diff --git a/framework-laptop-tools/src/hardware.cpp b/framework-laptop-tools/src/hardware.cpp new file mode 100644 index 0000000..94241c0 --- /dev/null +++ b/framework-laptop-tools/src/hardware.cpp @@ -0,0 +1,306 @@ +// SPDX-License-Identifier: MIT +#include "hardware.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +QString readText(const QString &path) +{ + QFile file(path); + return file.open(QIODevice::ReadOnly) ? QString::fromUtf8(file.readAll()).trimmed() : QString(); +} +std::optional readNumber(const QString &path) +{ + bool ok; + const double n = readText(path).toDouble(&ok); + return ok && std::isfinite(n) ? std::optional(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 temperatureSensors(const QString &sys) +{ + QVector sensors; + const QMap 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 frequencySensors(const QString &sys) +{ + QVector 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 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(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(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 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 cpuPolicyGroups(const QString &sys) +{ + QMap> 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 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{}; +} +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 &duties, const QVector &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(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(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; +} diff --git a/framework-laptop-tools/src/hardware.h b/framework-laptop-tools/src/hardware.h new file mode 100644 index 0000000..8e5650d --- /dev/null +++ b/framework-laptop-tools/src/hardware.h @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +#pragma once +#include +#include +#include +#include + +struct Sensor { + QString id, name, path, unit; + double scale = 1; + bool primary = false; +}; +QString readText(const QString &path); +std::optional readNumber(const QString &path); +QString compatibilityError(const QString &sys = QStringLiteral("/sys")); +QString ecHwmon(const QString &sys = QStringLiteral("/sys")); +QVector temperatureSensors(const QString &sys = QStringLiteral("/sys")); +QVector frequencySensors(const QString &sys = QStringLiteral("/sys")); +QVariantMap batteryStatus(const QString &sys = QStringLiteral("/sys")); +QMap cpuPolicyGroups(const QString &sys = QStringLiteral("/sys")); +QVariantMap cpuLimits(const QString &sys = QStringLiteral("/sys"), const QString &group = {}); +std::optional sensorValue(const Sensor &sensor); +std::optional batteryRate(const QVariantMap &battery); +double curveDuty(double temperature, const QVector &duties, const QVector &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); diff --git a/framework-laptop-tools/src/helper.cpp b/framework-laptop-tools/src/helper.cpp new file mode 100644 index 0000000..a9b05a8 --- /dev/null +++ b/framework-laptop-tools/src/helper.cpp @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MIT +#include "hardware.h" +#include "fan.h" +#include "cpu.h" +#include +#include +#include +#include +#include +#include +#include + +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(2)) data["fanMode"] = "auto"; + else if (mode == std::optional(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" diff --git a/framework-laptop-tools/src/legend.cpp b/framework-laptop-tools/src/legend.cpp new file mode 100644 index 0000000..617c860 --- /dev/null +++ b/framework-laptop-tools/src/legend.cpp @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MIT +#include "legend.h" +#include + +Legend::Legend(Chart *chart, const QVector &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(" %5") + .arg(i).arg(text.name(), enabled ? "none" : "line-through", dot.name(), sensor.name.toHtmlEscaped().replace(' ', " "))); + } + if (!m_extraLink.isEmpty()) links.append(QString("%2") + .arg(palette().color(QPalette::WindowText).name(), m_extraLink.toHtmlEscaped().replace(' ', " "))); + setText(links.join("     ")); +} +void Legend::changeEvent(QEvent *event) +{ + QLabel::changeEvent(event); + if (event->type() == QEvent::PaletteChange || event->type() == QEvent::ApplicationPaletteChange) refresh(); +} diff --git a/framework-laptop-tools/src/legend.h b/framework-laptop-tools/src/legend.h new file mode 100644 index 0000000..08a0d49 --- /dev/null +++ b/framework-laptop-tools/src/legend.h @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +#pragma once +#include "chart.h" +#include "hardware.h" +#include + +// 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 &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 m_sensors; + QString m_extraLink; +}; diff --git a/framework-laptop-tools/src/main.cpp b/framework-laptop-tools/src/main.cpp new file mode 100644 index 0000000..c57cff9 --- /dev/null +++ b/framework-laptop-tools/src/main.cpp @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT +#include "window.h" +#include +#include +#include +#include + +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" diff --git a/framework-laptop-tools/src/tray.cpp b/framework-laptop-tools/src/tray.cpp new file mode 100644 index 0000000..06ede73 --- /dev/null +++ b/framework-laptop-tools/src/tray.cpp @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: MIT +#include "tray.h" +#include +#include +#include +#include + +std::optional 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 &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 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); +} diff --git a/framework-laptop-tools/src/tray.h b/framework-laptop-tools/src/tray.h new file mode 100644 index 0000000..f96b9d0 --- /dev/null +++ b/framework-laptop-tools/src/tray.h @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +#pragma once +#include +#include +#include +#include + +class CpuUsage { +public: + std::optional sample(const QString &procStat); + void reset() { m_previous.reset(); } +private: + struct Counters { quint64 total, idle; }; + std::optional 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 &values, const QString &unit, const TrayStyle &style); diff --git a/framework-laptop-tools/src/traypage.cpp b/framework-laptop-tools/src/traypage.cpp new file mode 100644 index 0000000..094d602 --- /dev/null +++ b/framework-laptop-tools/src/traypage.cpp @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: MIT +#include "traypage.h" +#include "colorbutton.h" +#include +#include +#include +#include +#include + +TrayPage::TrayPage(QSettings &settings, const QVector &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; +} diff --git a/framework-laptop-tools/src/traypage.h b/framework-laptop-tools/src/traypage.h new file mode 100644 index 0000000..167892d --- /dev/null +++ b/framework-laptop-tools/src/traypage.h @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT +#pragma once +#include "hardware.h" +#include "tray.h" +#include +#include +#include +#include +#include +#include +#include + +class TrayPage : public QWidget { + Q_OBJECT +public: + TrayPage(QSettings &settings, const QVector &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> m_reload; + bool m_loading = false; + QVector 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; +}; diff --git a/framework-laptop-tools/src/valuecontrol.cpp b/framework-laptop-tools/src/valuecontrol.cpp new file mode 100644 index 0000000..b522f46 --- /dev/null +++ b/framework-laptop-tools/src/valuecontrol.cpp @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +#include "valuecontrol.h" +#include +#include +#include + +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())); +} diff --git a/framework-laptop-tools/src/valuecontrol.h b/framework-laptop-tools/src/valuecontrol.h new file mode 100644 index 0000000..57f7df5 --- /dev/null +++ b/framework-laptop-tools/src/valuecontrol.h @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +#pragma once +#include +#include +#include + +// 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; +}; diff --git a/framework-laptop-tools/src/window.cpp b/framework-laptop-tools/src/window.cpp new file mode 100644 index 0000000..ddb6450 --- /dev/null +++ b/framework-laptop-tools/src/window.cpp @@ -0,0 +1,548 @@ +// SPDX-License-Identifier: MIT +#include "window.h" +#include "legend.h" +#include "fan.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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>>{ + {"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{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 &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 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 &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 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 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 sensors{{"cpu-usage", "CPU usage", {}, "%"}}; + sensors += m_frequencies; sensors += m_temperatures; + sensors += QVector{{"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 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 &sensors, Chart *chart) { + QMap 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 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 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 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(); +} diff --git a/framework-laptop-tools/src/window.h b/framework-laptop-tools/src/window.h new file mode 100644 index 0000000..32caba2 --- /dev/null +++ b/framework-laptop-tools/src/window.h @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MIT +#pragma once +#include "chart.h" +#include "cpupage.h" +#include "tray.h" +#include "traypage.h" +#include +#include +#include +#include +#include +#include +#include +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 &sensors, bool temperatures); + QSettings m_settings; + QTimer m_fastTimer, m_batteryTimer, m_controlsTimer; + QTabWidget *m_tabs; + QVector m_temperatures, m_frequencies; + QMap m_values; + CpuUsage m_cpuUsage; + QVector 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 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 m_curve; + QVector m_curveTemperatures; + QSet m_dirtyControls; + bool m_loadingControls = false; + QMap 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); +}; diff --git a/framework-laptop-tools/tests/test-hardware.cpp b/framework-laptop-tools/tests/test-hardware.cpp new file mode 100644 index 0000000..3973e3c --- /dev/null +++ b/framework-laptop-tools/tests/test-hardware.cpp @@ -0,0 +1,242 @@ +// SPDX-License-Identifier: MIT +#include "hardware.h" +#include "fan.h" +#include "cpu.h" +#include +#include +#include +#include + +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(-12.3)); + QCOMPARE(batteryRate({{"watts", 12.3}, {"state", "Charging"}}), std::optional(12.3)); + QCOMPARE(batteryRate({{"watts", 0}, {"state", "Full"}}), std::optional(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> 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(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(false)); + put(ac + "online", "1"); + QCOMPARE(onAcPower(root.path()), std::optional(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" diff --git a/framework-laptop-tools/tests/test-window.cpp b/framework-laptop-tools/tests/test-window.cpp new file mode 100644 index 0000000..5db3905 --- /dev/null +++ b/framework-laptop-tools/tests/test-window.cpp @@ -0,0 +1,361 @@ +// SPDX-License-Identifier: MIT +#include "window.h" +#include +#include +#include +#include +#include +#include +#include "legend.h" +#include "tray.h" +#include +#include +#include +#include +#include "traypage.h" +#include "colorbutton.h" +#include + +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(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{}, QVector{{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("trayDisplayMode"); mode->setCurrentIndex(1); + auto *metric = page.findChild("trayMetric"); + auto *minimum = page.findChild("trayMinimum"); auto *maximum = page.findChild("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(); auto *number = control.findChild(); + 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(); 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().size(), 4); + for (auto *toggle : window.findChildren("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("powerAuto"); QVERIFY(powerAuto); + auto *brightness = window.findChild("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("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("enableCpuOverrides")); + auto *battery = page.findChild("batteryCpuProfile"); QVERIFY(battery->isEnabled()); + auto *frequency = battery->findChild("overrideFrequency"); QVERIFY(frequency); QVERIFY(!frequency->isChecked()); + QVERIFY(!battery->findChild()->isEnabled()); + QCOMPARE(battery->findChild("cpuGovernor")->currentText(), QString("auto")); + QCOMPARE(battery->findChild("cpuPreference")->currentText(), QString("auto")); + for (auto *spin : page.findChildren()) spin->setRange(400, 10000); + for (auto *combo : page.findChildren()) { + 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("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("acCpuProfile"); QVERIFY(ac); + auto boxes = ac->findChildren(); 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("pendingChanges"); QVERIFY(bar); + auto *save = window.findChild("saveAll"); + auto *undo = window.findChild("undoAll"); + auto *tabs = window.findChild(); + QTRY_VERIFY(save->isEnabled()); QVERIFY(bar->isHidden()); + auto *fast = window.findChild("fastInterval"); + auto *mode = window.findChild("trayDisplayMode"); + auto *autostart = window.findChild("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(); QCOMPARE(charts.size(), 4); + const qint64 time = QDateTime::currentMSecsSinceEpoch() - 1000; + charts.first()->hovered(time); + for (auto *chart : charts) QCOMPARE(chart->hoverTime(), std::optional(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("saveAll"); + QTRY_VERIFY(save->isEnabled()); + auto *mode = window.findChild("fanMode"); + auto *duty = window.findChild("fanDuty"); + mode->setCurrentIndex(1); duty->setValue(0); + QVERIFY(!window.findChild("lowFanWarning")->isHidden()); + bool sawConfirmation = false; + QTimer::singleShot(0, &window, [&] { + auto *dialog = qobject_cast(QApplication::activeModalWidget()); + if (dialog) { sawConfirmation = dialog->text().contains("0%"); dialog->done(QMessageBox::Cancel); } + }); + save->click(); QVERIFY(sawConfirmation); + QVERIFY(!window.findChild("pendingChanges")->isHidden()); + QVERIFY(save->isEnabled()); // No authorisation or hardware operation started. + window.findChild("undoAll")->click(); + QVERIFY(window.findChild("pendingChanges")->isHidden()); + } +}; +QTEST_MAIN(WindowTest) +#include "test-window.moc" diff --git a/scripts/build-framework-laptop-tools-rpm b/scripts/build-framework-laptop-tools-rpm new file mode 100755 index 0000000..2fae316 --- /dev/null +++ b/scripts/build-framework-laptop-tools-rpm @@ -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"