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