// 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(); }