Files
fedora-tools/framework-laptop-tools/src/window.cpp
T

549 lines
32 KiB
C++

// SPDX-License-Identifier: MIT
#include "window.h"
#include "legend.h"
#include "fan.h"
#include <QMessageBox>
#include <KAuth/Action>
#include <KAuth/ExecuteJob>
#include <KJob>
#include <QApplication>
#include <QCloseEvent>
#include <QDBusInterface>
#include <QDBusReply>
#include <QFormLayout>
#include <QGroupBox>
#include <QMenu>
#include <QPushButton>
#include <QScrollArea>
#include <QTabWidget>
#include <QVBoxLayout>
#include <QFile>
#include <QSaveFile>
#include <QStandardPaths>
#include <QDir>
#include <QFileInfo>
#include <cmath>
namespace {
QLabel *note(const QString &text) { auto *label = new QLabel(text); label->setWordWrap(true); return label; }
ValueControl *spin(int minimum, int maximum, const QString &suffix) {
auto *box = new ValueControl; box->setRange(minimum, maximum); box->setSuffix(suffix); return box;
}
QString duration(double seconds) {
if (!std::isfinite(seconds) || seconds < 60) return "less than a minute";
return QString("%1 h %2 min").arg(int(seconds) / 3600).arg(int(seconds) / 60 % 60);
}
}
Window::Window() : m_settings("fedora-tools", "framework-laptop-tools"),
m_temperatures(temperatureSensors()), m_frequencies(frequencySensors())
{
for (const auto &entry : QList<QPair<QString, QList<int>>>{
{"sampling/fast", {1000, 500, 2000, 4000}},
{"sampling/battery", {30000, 15000, 60000, 120000}}}) {
if (!entry.second.contains(m_settings.value(entry.first).toInt()))
m_settings.setValue(entry.first, entry.second.first());
}
setWindowTitle("Framework Laptop Tools"); setWindowIcon(QIcon::fromTheme("computer-laptop")); resize(1000, 830);
auto *central = new QWidget; auto *layout = new QVBoxLayout(central); setCentralWidget(central);
m_message = note(compatibilityError()); layout->addWidget(m_message);
auto *tabs = new QTabWidget; m_tabs = tabs; layout->addWidget(tabs);
auto addPage = [&](QWidget *page, const QString &title) {
auto *scroll = new QScrollArea; scroll->setWidgetResizable(true); scroll->setWidget(page); tabs->addTab(scroll, title);
};
addPage(monitorPage(), "Monitor");
m_cpuPage = new CpuPage;
m_controlPages = {lightingPage(), coolingPage(), batteryPage(), m_cpuPage};
const QStringList titles{"Lighting", "Cooling", "Battery", "CPU"};
for (int i = 0; i < m_controlPages.size(); ++i) {
addPage(m_controlPages[i], titles[i]);
m_controlPages[i]->setEnabled(compatibilityError().isEmpty());
}
addPage(trayPage(), "Tray icon");
addPage(preferencesPage(), "Preferences");
m_savedTray = m_trayPage->draft(); m_savedPreferences = preferenceValues();
m_trayMetric = m_trayPage->metric(); m_trayMode = m_trayPage->mode(); m_trayStyle = m_trayPage->iconStyle();
for (const auto &key : {"keyboard", "power", "chargeLimit", "chargeWatts", "fan"}) m_savedControls[key] = controlValue(key);
m_pendingBar = new QWidget; m_pendingBar->setObjectName("pendingChanges");
auto *bar = new QHBoxLayout(m_pendingBar);
bar->addWidget(new QLabel("Unsaved changes")); bar->addStretch();
m_saveButton = new QPushButton("Save and Apply"); m_saveButton->setObjectName("saveAll");
m_undoButton = new QPushButton("Undo changes"); m_undoButton->setObjectName("undoAll");
bar->addWidget(m_saveButton); bar->addWidget(m_undoButton); layout->addWidget(m_pendingBar);
connect(m_saveButton, &QPushButton::clicked, this, &Window::saveChanges);
connect(m_undoButton, &QPushButton::clicked, this, &Window::undoChanges);
connect(m_cpuPage, &CpuPage::draftChanged, this, &Window::updatePendingBar);
auto dirty = [this](const QString &key) {
if (m_loadingControls) return;
if (controlValue(key) == m_savedControls[key]) m_dirtyControls.remove(key); else m_dirtyControls.insert(key);
updatePendingBar();
};
connect(m_keyboard, &ValueControl::valueChanged, this, [dirty] { dirty("keyboard"); });
connect(m_power, &ValueControl::valueChanged, this, [dirty] { dirty("power"); });
connect(m_powerAuto, &QCheckBox::toggled, this, [dirty] { dirty("power"); });
connect(m_charge, &ValueControl::valueChanged, this, [dirty] { dirty("chargeLimit"); });
connect(m_watts, &ValueControl::valueChanged, this, [dirty] { dirty("chargeWatts"); });
connect(m_fanMode, &QComboBox::currentIndexChanged, this, [dirty] { dirty("fan"); });
for (auto *control : m_curve + m_curveTemperatures + QVector<ValueControl *>{m_duty})
connect(control, &ValueControl::valueChanged, this, [dirty] { dirty("fan"); });
connect(tabs, &QTabWidget::currentChanged, this, [this](int index) {
if (index >= 1 && index <= 4) refreshControls();
});
connect(&m_controlsTimer, &QTimer::timeout, this, [this] {
if (isVisible() && !m_sleeping && m_tabs->currentIndex() >= 1 && m_tabs->currentIndex() <= 3) refreshControls();
});
m_controlsTimer.start(2000);
m_tray = new QSystemTrayIcon(windowIcon(), this);
auto *menu = new QMenu(this);
menu->addAction("Open Framework Laptop Tools", this, [this] { show(); raise(); activateWindow(); });
menu->addSeparator();
menu->addAction("Quit", qApp, &QApplication::quit);
m_tray->setContextMenu(menu);
connect(m_tray, &QSystemTrayIcon::activated, this, [this](auto reason) {
if (reason == QSystemTrayIcon::Trigger || reason == QSystemTrayIcon::DoubleClick) { show(); raise(); activateWindow(); }
});
m_tray->show();
if (!QDBusConnection::systemBus().connect("org.freedesktop.login1", "/org/freedesktop/login1",
"org.freedesktop.login1.Manager", "PrepareForSleep", this, SLOT(sleepChanged(bool))))
m_message->setText("Sleep notifications unavailable; sleep intervals cannot be marked on the battery graph.");
connect(&m_fastTimer, &QTimer::timeout, this, &Window::sample);
connect(&m_batteryTimer, &QTimer::timeout, this, &Window::sampleBattery);
m_fastTimer.start(m_settings.value("sampling/fast", 1000).toInt());
m_batteryTimer.start(m_settings.value("sampling/battery", 30000).toInt());
updatePendingBar(); sample(); sampleBattery(); refreshControls();
}
QGroupBox *Window::sensorGroup(const QString &title, Chart *chart, const QVector<Sensor> &sensors, bool temperatures)
{
auto *group = new QGroupBox; auto *layout = new QVBoxLayout(group);
auto *heading = new QHBoxLayout;
auto *label = new QLabel(title); label->setObjectName("chartTitle");
auto font = label->font(); font.setPointSizeF(font.pointSizeF() + 2); label->setFont(font);
heading->addWidget(label);
if (title == "Fan speed" || title == "Battery") {
auto *details = new QLabel; details->setWordWrap(true); heading->addWidget(details, 1);
if (title == "Fan speed") m_fanDetails = details; else m_batteryDetails = details;
} else heading->addStretch();
layout->addLayout(heading);
QVector<Sensor> main, extra;
for (const auto &sensor : sensors) {
chart->addSeries(sensor.id, sensor.name, sensor.unit);
chart->setSelected(sensor.id, m_settings.value("series/" + sensor.id, !temperatures || sensor.primary).toBool());
(temperatures && !sensor.primary ? extra : main).append(sensor);
}
auto makeLegend = [&](const QVector<Sensor> &entries) {
auto *legend = new Legend(chart, entries);
connect(legend, &Legend::selectionChanged, this, [this](const QString &id, bool selected) {
m_settings.setValue("series/" + id, selected);
});
return legend;
};
auto *mainLegend = makeLegend(main); layout->addWidget(mainLegend);
if (!extra.isEmpty()) {
auto *legend = makeLegend(extra); legend->hide();
auto updateToggle = [mainLegend, legend] {
mainLegend->setExtraLink(QString("%1 More sensors (%2 enabled)")
.arg(legend->isHidden() ? "▸" : "▾").arg(legend->enabledCount()));
};
connect(mainLegend, &Legend::extraActivated, this, [legend, updateToggle] {
legend->setVisible(legend->isHidden()); updateToggle();
});
connect(legend, &Legend::selectionChanged, this, updateToggle);
updateToggle(); layout->addWidget(legend);
}
layout->addWidget(chart);
return group;
}
QWidget *Window::monitorPage()
{
auto *page = new QWidget; auto *layout = new QVBoxLayout(page);
m_frequencyChart = new Chart("MHz"); m_temperatureChart = new Chart("°C"); m_batteryChart = new Chart("%");
layout->addWidget(sensorGroup("CPU and GPU frequency", m_frequencyChart, m_frequencies, false));
m_fanChart = new Chart("RPM");
auto *fanGroup = sensorGroup("Fan speed", m_fanChart, {{"fan", "Fan speed", {}, "RPM"}}, false);
layout->addWidget(fanGroup);
layout->addWidget(sensorGroup("Temperatures", m_temperatureChart, m_temperatures, true));
auto *batteryGroup = sensorGroup("Battery", m_batteryChart,
{{"battery", "Charge level", {}, "%"}, {"battery-rate", "Charge / discharge rate", {}, "W"}}, false);
layout->addWidget(batteryGroup);
const QList<Chart *> charts{m_frequencyChart, m_fanChart, m_temperatureChart, m_batteryChart};
for (auto *source : charts) connect(source, &Chart::hovered, this, [charts](qint64 time) {
for (auto *chart : charts) chart->setHoverTime(time);
});
return page;
}
QWidget *Window::lightingPage()
{
auto *page = new QWidget; auto *layout = new QVBoxLayout(page);
auto *keyboard = new QGroupBox("Keyboard"); auto *keys = new QFormLayout(keyboard);
m_keyboard = spin(0, 100, "%"); m_keyboard->setObjectName("keyboardBrightness");
keys->addRow("Keyboard brightness:", m_keyboard);
keys->addRow(note("The current firmware interface does not expose setting or detecting automatic brightness. If Auto is set using Fn+Space, any manual brightness here will be overridden."));
layout->addWidget(keyboard);
auto *power = new QGroupBox("Power button"); auto *lights = new QFormLayout(power);
m_power = spin(1, 100, "%"); m_power->setObjectName("powerBrightness");
m_powerAuto = new QCheckBox("Automatic brightness"); m_powerAuto->setObjectName("powerAuto");
lights->addRow(m_powerAuto); lights->addRow("Power-button brightness:", m_power);
connect(m_powerAuto, &QCheckBox::toggled, m_power, &QWidget::setDisabled);
m_firmwareReadout = note(""); lights->addRow(m_firmwareReadout);
layout->addWidget(power); layout->addStretch(); return page;
}
QWidget *Window::coolingPage()
{
auto *page = new QWidget; auto *layout = new QVBoxLayout(page);
auto *fans = new QGroupBox; auto *form = new QFormLayout(fans);
m_fanReadout = note(""); form->addRow(m_fanReadout);
m_fanMode = new QComboBox; m_fanMode->setObjectName("fanMode");
m_fanMode->addItems({"Firmware Auto", "Manual", "Curve"}); form->addRow("Fan mode:", m_fanMode);
m_duty = spin(0, 100, "%"); m_duty->setObjectName("fanDuty"); m_duty->setValue(50);
form->addRow("Manual speed:", m_duty);
m_fanWarning = note("Warning: below 30% the fan may stop or provide insufficient cooling.");
m_fanWarning->setObjectName("lowFanWarning"); form->addRow(m_fanWarning);
const QList<int> defaults{30, 40, 70, 100};
for (int i = 0; i < 4; ++i) {
auto *point = spin(0, 100, "%"); point->setValue(defaults[i]); m_curve.append(point);
auto *temperature = spin(20, 85, " °C"); temperature->setValue(40 + 15 * i); m_curveTemperatures.append(temperature);
auto *row = new QWidget; auto *line = new QHBoxLayout(row); line->setContentsMargins(0, 0, 0, 0);
line->addWidget(temperature); line->addWidget(point); form->addRow(QString("Curve point %1:").arg(i + 1), row);
}
auto modeChanged = [this] {
const int index = m_fanMode->currentIndex();
m_duty->setEnabled(index == 1);
m_fanWarning->setVisible(index == 1 && m_duty->value() < 30);
for (int i = 0; i < m_curve.size(); ++i) m_curve[i]->setEnabled(index == 2 && i != 3);
for (auto *point : m_curveTemperatures) point->setEnabled(index == 2);
};
connect(m_fanMode, &QComboBox::currentIndexChanged, this, modeChanged);
connect(m_duty, &ValueControl::valueChanged, this, modeChanged); modeChanged();
form->addRow(note("Uses the hottest EC sensor. The final curve point must reach 100% by 85 °C."));
fans->setEnabled(!ecHwmon().isEmpty()); layout->addWidget(fans); layout->addStretch(); return page;
}
QWidget *Window::batteryPage()
{
auto *page = new QWidget; auto *layout = new QVBoxLayout(page);
auto *group = new QGroupBox("Battery"); auto *form = new QFormLayout(group);
m_charge = spin(50, 100, "%"); m_charge->setValue(100); m_charge->setObjectName("chargeLimit");
form->addRow("Limit battery:", m_charge);
m_watts = spin(0, 75, " W"); m_watts->setSpecialValueText("Firmware default"); m_watts->setObjectName("chargeWatts");
m_watts->setToolTip("Zero restores firmware defaults. The current charging-power limit cannot be read back.");
form->addRow("Limit charging power:", m_watts);
form->addRow(note("Limiting charging power is approximate."));
layout->addWidget(group); layout->addStretch(); return page;
}
QWidget *Window::preferencesPage()
{
auto *page = new QWidget; auto *layout = new QFormLayout(page);
m_fastChoice = new QComboBox; m_fastChoice->setObjectName("fastInterval");
m_batteryChoice = new QComboBox; m_batteryChoice->setObjectName("batteryInterval");
for (int ms : {500, 1000, 2000, 4000}) m_fastChoice->addItem(ms == 1000 ? "1 second" : QString("%1 seconds").arg(ms / 1000.), ms);
for (int ms : {15000, 30000, 60000, 120000}) m_batteryChoice->addItem(ms < 60000 ? QString("%1 seconds").arg(ms / 1000) : ms == 60000 ? "1 minute" : "2 minutes", ms);
m_fastChoice->setCurrentIndex(std::max(0, m_fastChoice->findData(m_settings.value("sampling/fast", 1000))));
m_batteryChoice->setCurrentIndex(std::max(0, m_batteryChoice->findData(m_settings.value("sampling/battery", 30000))));
layout->addRow("Frequency / temperature updates:", m_fastChoice); layout->addRow("Battery graph updates:", m_batteryChoice);
m_autostart = new QCheckBox("Start in the tray when I log in"); m_autostart->setObjectName("loginAutostart");
m_autostartPath = QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation) + "/autostart/se.ajpanton.framework-laptop-tools.desktop";
m_autostart->setChecked(QFile::exists(m_autostartPath)); layout->addRow(m_autostart);
connect(m_fastChoice, &QComboBox::currentIndexChanged, this, &Window::updatePendingBar);
connect(m_batteryChoice, &QComboBox::currentIndexChanged, this, &Window::updatePendingBar);
connect(m_autostart, &QCheckBox::toggled, this, &Window::updatePendingBar);
return page;
}
QWidget *Window::trayPage()
{
QVector<Sensor> sensors{{"cpu-usage", "CPU usage", {}, "%"}};
sensors += m_frequencies; sensors += m_temperatures;
sensors += QVector<Sensor>{{"battery", "Battery charge", {}, "%"}, {"battery-rate", "Battery rate", {}, "W"}};
m_trayPage = new TrayPage(m_settings, sensors);
connect(m_trayPage, &TrayPage::settingsChanged, this, &Window::updatePendingBar);
return m_trayPage;
}
void Window::updateTray()
{
const auto metric = m_trayMetric;
QVector<QPointF> history;
if (metric.id == "cpu-usage") history = m_usageHistory;
else if (metric.id == "battery" || metric.id == "battery-rate") history = m_batteryChart->history(metric.id);
else history = (metric.unit == "MHz" ? m_frequencyChart : m_temperatureChart)->history(metric.id);
if (m_trayMode == "icon") m_tray->setIcon(windowIcon());
else m_tray->setIcon(telemetryIcon(m_trayMode == "graph", history, metric.unit, m_trayStyle));
const auto value = m_values.constFind(metric.id);
m_tray->setToolTip(metric.name + ": " + (value == m_values.cend() ? "—" : QString::number(*value, 'f', 1) + " " + metric.unit)
+ "\n" + m_batteryText + "\n" + m_fanReadout->text());
}
QVariantMap Window::controlValue(const QString &key) const
{
if (key == "power") return m_powerAuto->isChecked() ? QVariantMap{{"operation", "powerAuto"}, {"value", m_power->value()}}
: QVariantMap{{"operation", "powerBrightness"}, {"value", m_power->value()}};
if (key == "fan") {
QVariantList curve, temperatures;
for (auto *point : m_curve) curve.append(point->value());
for (auto *point : m_curveTemperatures) temperatures.append(point->value());
return {{"operation", "fan"}, {"mode", QStringList{"auto", "manual", "curve"}[m_fanMode->currentIndex()]},
{"duty", m_duty->value()}, {"curve", curve}, {"temperatures", temperatures}};
}
auto *control = key == "keyboard" ? m_keyboard : key == "chargeLimit" ? m_charge : m_watts;
return {{"operation", key}, {"value", control->value()}};
}
void Window::loadControl(const QString &key, const QVariantMap &value)
{
if (key == "power") {
m_powerAuto->setChecked(value["operation"] == "powerAuto");
if (value.contains("value")) m_power->setValue(value["value"].toInt());
} else if (key == "fan") {
m_fanMode->setCurrentIndex(QStringList{"auto", "manual", "curve"}.indexOf(value["mode"].toString()));
m_duty->setValue(value["duty"].toInt());
for (int i = 0; i < m_curve.size(); ++i) {
m_curve[i]->setValue(value["curve"].toList()[i].toInt());
m_curveTemperatures[i]->setValue(value["temperatures"].toList()[i].toInt());
}
} else {
auto *control = key == "keyboard" ? m_keyboard : key == "chargeLimit" ? m_charge : m_watts;
control->setValue(value["value"].toInt());
}
}
QVariantMap Window::preferenceValues() const
{
return {{"sampling/fast", m_fastChoice->currentData()}, {"sampling/battery", m_batteryChoice->currentData()},
{"autostart", m_autostart->isChecked()}};
}
void Window::updatePendingBar()
{
if (!m_pendingBar || m_loadingControls) return;
const bool dirty = !m_dirtyControls.isEmpty() || m_cpuPage->dirty() ||
m_trayPage->draft() != m_savedTray || preferenceValues() != m_savedPreferences;
m_pendingBar->setVisible(dirty || m_saving);
m_saveButton->setEnabled(!m_busy && !m_saving); m_undoButton->setEnabled(!m_busy && !m_saving);
m_tabs->setEnabled(!m_saving);
}
void Window::undoChanges()
{
if (m_busy || m_saving) return;
m_loadingControls = true;
for (auto it = m_savedControls.cbegin(); it != m_savedControls.cend(); ++it) loadControl(it.key(), it.value());
m_dirtyControls.clear(); m_cpuPage->undo(); m_trayPage->load(m_savedTray);
m_fastChoice->setCurrentIndex(m_fastChoice->findData(m_savedPreferences["sampling/fast"]));
m_batteryChoice->setCurrentIndex(m_batteryChoice->findData(m_savedPreferences["sampling/battery"]));
m_autostart->setChecked(m_savedPreferences["autostart"].toBool());
m_loadingControls = false; m_message->clear(); updatePendingBar(); refreshControls();
}
QStringList Window::dangerousChanges() const
{
QStringList warnings;
if (m_dirtyControls.contains("fan")) {
if (m_fanMode->currentIndex() == 1 && m_duty->value() < 30)
warnings << QString("Manual fan speed: %1%. The fan may stop or provide insufficient cooling.").arg(m_duty->value());
if (m_fanMode->currentIndex() == 2) {
for (auto *point : m_curve) if (point->value() < 30) {
warnings << "Fan curve includes speeds below 30%. The fan may stop at those temperatures."; break;
}
}
}
return warnings;
}
void Window::saveChanges()
{
if (m_busy || m_saving) return;
QString error;
if (m_dirtyControls.contains("fan")) error = validateFan(controlValue("fan"));
if (error.isEmpty() && m_cpuPage->dirty()) error = validateCpuConfig(m_cpuPage->draft());
if (!error.isEmpty()) { m_message->setText(error); return; }
const auto warnings = dangerousChanges();
if (!warnings.isEmpty()) {
QMessageBox confirmation(QMessageBox::Warning, "Apply potentially dangerous settings?",
warnings.join("\n\n") + "\n\nThese settings will also be restored after reboot and sleep. Apply them?",
QMessageBox::Yes | QMessageBox::Cancel, this);
confirmation.setDefaultButton(QMessageBox::Cancel);
if (confirmation.exec() != QMessageBox::Yes) return;
}
QVariantList operations;
for (const auto &key : {"keyboard", "power", "chargeLimit", "chargeWatts", "fan"})
if (m_dirtyControls.contains(key)) operations.append(controlValue(key));
if (m_cpuPage->dirty()) operations.append(QVariantMap{{"operation", "cpuProfiles"}, {"config", m_cpuPage->draft()}});
m_saving = true; updatePendingBar();
if (operations.isEmpty()) finishSave(); else request({{"operation", "batch"}, {"operations", operations}});
}
QString Window::saveLocalSettings()
{
const auto preferences = preferenceValues();
if (preferences["autostart"] != m_savedPreferences["autostart"]) {
if (m_autostart->isChecked()) {
if (!QDir().mkpath(QFileInfo(m_autostartPath).absolutePath())) return "Could not create the autostart directory.";
QSaveFile file(m_autostartPath);
const QByteArray data("[Desktop Entry]\nType=Application\nName=Framework Laptop Tools\nExec=framework-laptop-tools --tray\nIcon=computer-laptop\n");
if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size() || !file.commit())
return "Could not save login autostart: " + file.errorString();
} else if (QFile::exists(m_autostartPath) && !QFile::remove(m_autostartPath)) return "Could not remove login autostart.";
}
const auto tray = m_trayPage->draft();
for (const auto &key : m_settings.allKeys()) if (key.startsWith("tray/")) m_settings.remove(key);
for (auto it = tray.cbegin(); it != tray.cend(); ++it) m_settings.setValue(it.key(), it.value());
for (const auto &key : {"sampling/fast", "sampling/battery"}) m_settings.setValue(key, preferences[key]);
m_settings.sync();
if (m_settings.status() != QSettings::NoError) return "Could not save application settings.";
m_savedTray = tray; m_savedPreferences = preferences;
m_fastTimer.start(preferences["sampling/fast"].toInt()); m_batteryTimer.start(preferences["sampling/battery"].toInt());
m_trayMetric = m_trayPage->metric(); m_trayMode = m_trayPage->mode(); m_trayStyle = m_trayPage->iconStyle(); updateTray();
return {};
}
void Window::finishSave()
{
const auto error = saveLocalSettings();
m_saving = false;
m_message->setText(error.isEmpty() ? "Settings saved and applied." : "Save stopped: " + error + " Earlier changes may already have applied.");
updatePendingBar(); refreshControls();
}
void Window::refreshControls()
{
if (m_busy || m_saving) return;
m_loadingControls = true;
const auto keyboard = readNumber("/sys/class/leds/chromeos::kbd_backlight/brightness");
m_keyboard->setEnabled(keyboard.has_value());
if (keyboard && !m_dirtyControls.contains("keyboard")) {
m_keyboard->setValue(int(*keyboard)); m_savedControls["keyboard"] = controlValue("keyboard");
}
m_loadingControls = false;
if (compatibilityError().isEmpty()) request({}, true);
}
void Window::request(const QVariantMap &arguments, bool inspect)
{
if (m_busy) return;
if (!compatibilityError().isEmpty()) {
m_saving = false; m_message->setText(compatibilityError()); updatePendingBar(); return;
}
m_busy = true; updatePendingBar();
if (!inspect) m_message->setText("Applying settings…");
KAuth::Action action(QString("se.ajpanton.frameworktools.") + (inspect ? "inspect" : "configure"));
action.setHelperId("se.ajpanton.frameworktools"); action.setArguments(arguments); action.setTimeout(inspect ? 60000 : 180000);
auto *job = action.execute();
connect(job, &KJob::result, this, [this, job, inspect](KJob *) {
m_busy = false;
if (job->error()) {
m_message->setText(inspect ? job->errorString() : "Save stopped: " + job->errorString() + " Earlier changes may already have applied; remaining edits are still unsaved.");
m_saving = false; updatePendingBar(); return;
}
if (inspect) {
m_loadingControls = true;
const auto data = job->data();
if (!data.contains("cpuError")) m_cpuPage->load(data["cpuConfig"].toMap());
if (data.contains("chargeLimit")) {
m_chargeLimit = data["chargeLimit"].toInt();
if (!m_dirtyControls.contains("chargeLimit")) m_charge->setValue(m_chargeLimit);
}
m_chargeOverride = data.value("chargeOverride").toBool();
if (data.contains("fanMode") && !m_dirtyControls.contains("fan")) {
m_fanMode->setCurrentIndex(QStringList{"auto", "manual", "curve"}.indexOf(data["fanMode"].toString()));
if (data.contains("fanDuty")) m_duty->setValue(data["fanDuty"].toInt());
const auto curve = data["fanCurve"].toList(), temperatures = data["fanTemperatures"].toList();
if (curve.size() == m_curve.size()) for (int i = 0; i < curve.size(); ++i) m_curve[i]->setValue(curve[i].toInt());
if (temperatures.size() == m_curveTemperatures.size()) for (int i = 0; i < temperatures.size(); ++i) m_curveTemperatures[i]->setValue(temperatures[i].toInt());
}
if (!m_dirtyControls.contains("power")) {
if (data.contains("powerBrightness")) m_power->setValue(data["powerBrightness"].toInt());
if (data.contains("powerAuto")) m_powerAuto->setChecked(data["powerAuto"].toBool());
}
for (const auto &key : {"power", "chargeLimit", "fan"}) if (!m_dirtyControls.contains(key)) m_savedControls[key] = controlValue(key);
m_firmwareReadout->setText(data.contains("powerBrightness") ? QString("Current: %1% (%2)").arg(data["powerBrightness"].toInt()).arg(data["powerAuto"].toBool() ? "Auto" : "fixed") : data["powerError"].toString());
for (const auto &key : {"cpuError", "chargeError", "fanError"}) if (data.contains(key)) m_message->setText(data[key].toString());
m_loadingControls = false; updatePendingBar();
} else {
const auto data = job->data();
for (const auto &entry : data.value("completed").toList()) {
const auto value = entry.toMap(); const auto operation = value["operation"].toString();
if (operation == "cpuProfiles") m_cpuPage->load(value["config"].toMap(), true);
else {
const auto key = operation.startsWith("power") ? QString("power") : operation;
m_savedControls[key] = value; m_dirtyControls.remove(key);
}
}
if (data.contains("applyError")) {
m_saving = false;
m_message->setText("Save stopped: " + data["applyError"].toString() + " Earlier changes may already have applied; remaining edits are still unsaved.");
updatePendingBar(); refreshControls();
} else finishSave();
}
});
job->start();
}
void Window::sample()
{
if (m_sleeping) return;
m_values.clear();
m_cpuPage->refreshStatus();
auto sampleSensors = [&](const QVector<Sensor> &sensors, Chart *chart) {
QMap<QString, double> values;
for (const auto &sensor : sensors) {
const auto n = sensorValue(sensor);
if (n) { values.insert(sensor.id, *n); m_values.insert(sensor.id, *n); }
}
chart->sample(values, m_fastTimer.interval());
};
sampleSensors(m_frequencies, m_frequencyChart); sampleSensors(m_temperatures, m_temperatureChart);
const QString ec = ecHwmon();
const auto rpm = readNumber(ec + "/fan1_input");
const auto mode = readNumber(ec + "/pwm1_enable");
const auto pwm = readNumber(ec + "/pwm1");
const QString fanMode = !mode ? "Mode unavailable" : *mode == 2 ? "Firmware Auto" : "Manual / curve override";
m_fanDetails->setText("· " + fanMode);
m_fanReadout->setText((rpm ? QString("%1 RPM").arg(*rpm, 0, 'f', 0) : "Fan speed unavailable")
+ (pwm ? QString(" · Duty: %1%").arg(*pwm * 100 / 255, 0, 'f', 0) : " · Duty unavailable") + " · " + fanMode);
QMap<QString, double> fans;
if (rpm) fans["fan"] = *rpm;
m_fanChart->sample(fans, m_fastTimer.interval());
const auto battery = batteryStatus();
const auto usage = m_cpuUsage.sample(readText("/proc/stat"));
if (usage) m_values["cpu-usage"] = *usage;
m_usageHistory.append({double(QDateTime::currentMSecsSinceEpoch()), usage.value_or(NAN)});
if (m_usageHistory.size() > 600) m_usageHistory.removeFirst();
if (battery.contains("capacity")) m_values["battery"] = battery["capacity"].toDouble();
const auto rate = batteryRate(battery); if (rate) m_values["battery-rate"] = *rate;
m_batteryChart->setPowerState(false, onAcPower());
QString text = battery.isEmpty() ? "Battery unavailable" : QString("Battery %1% · %2").arg(battery.value("capacity").toInt()).arg(battery.value("state").toString());
if (battery.contains("watts")) text += QString(" · %1 W").arg(battery["watts"].toDouble(), 0, 'f', 1);
if (battery.contains("health")) text += QString(" · Capacity / design %1%").arg(battery["health"].toDouble(), 0, 'f', 0);
if (battery.contains("cycle_count")) text += QString(" · %1 cycles").arg(battery["cycle_count"].toInt());
QStringList details;
if (battery.contains("fullMWh")) details << QString("Capacity: %1 mWh").arg(battery["fullMWh"].toDouble(), 0, 'f', 0);
if (battery.contains("health")) details << QString("Health: %1%").arg(battery["health"].toDouble(), 0, 'f', 0);
if (battery.contains("cycle_count")) details << QString("%1 cycles").arg(battery["cycle_count"].toInt());
m_batteryDetails->setText("· " + details.join(" · "));
const int effectiveLimit = m_chargeOverride ? 100 : m_chargeLimit;
QDBusInterface properties("org.freedesktop.UPower", "/org/freedesktop/UPower/devices/DisplayDevice", "org.freedesktop.DBus.Properties", QDBusConnection::systemBus());
properties.setTimeout(250);
const QDBusReply<QVariantMap> response = properties.call("GetAll", "org.freedesktop.UPower.Device");
if (response.isValid()) {
if (battery["state"] == "Discharging" && response.value()["TimeToEmpty"].toLongLong() > 0)
text += " · " + duration(response.value()["TimeToEmpty"].toDouble()) + " remaining (OS estimate)";
else if (battery["state"] == "Charging" && response.value()["TimeToFull"].toLongLong() > 0 && effectiveLimit == 100)
text += " · " + duration(response.value()["TimeToFull"].toDouble()) + " until full (OS estimate)";
}
if (battery["state"] == "Charging" && effectiveLimit < 100 && battery["current_now"].toDouble() > 0
&& battery.contains("charge_now") && battery["charge_full"].toDouble() > 0) {
const double remaining = battery["charge_full"].toDouble() * effectiveLimit / 100 - battery["charge_now"].toDouble();
text += remaining > 0 ? " · about " + duration(remaining / battery["current_now"].toDouble() * 3600) + QString(" to %1% (at current rate)").arg(effectiveLimit) : " · charge limit reached";
}
m_batteryText = text;
updateTray();
}
void Window::sampleBattery()
{
if (m_sleeping) return;
const auto battery = batteryStatus(); QMap<QString, double> values;
if (battery.contains("capacity")) values["battery"] = battery["capacity"].toDouble();
const auto rate = batteryRate(battery); if (rate) values["battery-rate"] = *rate;
m_batteryChart->sample(values, m_batteryTimer.interval());
updateTray();
}
void Window::sleepChanged(bool sleeping)
{
m_sleeping = sleeping;
for (auto *chart : {m_frequencyChart, m_temperatureChart, m_batteryChart, m_fanChart})
chart->setPowerState(sleeping, sleeping || chart != m_batteryChart ? std::nullopt : onAcPower());
m_cpuUsage.reset();
if (!sleeping) { sample(); sampleBattery(); }
}
void Window::closeEvent(QCloseEvent *event)
{
if (QSystemTrayIcon::isSystemTrayAvailable()) { hide(); event->ignore(); }
else event->accept();
}