Add Framework laptop controls and monitoring with hardware-aware discovery

This commit is contained in:
ajp_anton
2026-09-11 10:29:27 +00:00
parent 787231cb03
commit 8719402d97
50 changed files with 3611 additions and 9 deletions
+261
View File
@@ -0,0 +1,261 @@
// SPDX-License-Identifier: MIT
#include "chart.h"
#include <QPainter>
#include <QPainterPath>
#include <QMouseEvent>
#include <QToolTip>
#include <QVector3D>
#include <algorithm>
#include <cmath>
#include <limits>
namespace {
double niceStep(double value)
{
const double magnitude = std::pow(10., std::floor(std::log10(value)));
for (double factor : {1., 2., 5., 10.}) if (factor * magnitude >= value) return factor * magnitude;
return 10 * magnitude;
}
QString number(double value) { return QString::number(value, 'f', std::abs(value) < 10 && value != std::round(value) ? 1 : 0); }
// OKLab approximates perceptual distance. Greedy farthest-point selection leaves
// existing colours untouched and fills the largest remaining gap each time.
QVector3D perceptual(const QColor &c)
{
auto linear = [](double v) { return v <= .04045 ? v / 12.92 : std::pow((v + .055) / 1.055, 2.4); };
const double r = linear(c.redF()), g = linear(c.greenF()), b = linear(c.blueF());
const double l = std::cbrt(.4122214708*r + .5363325363*g + .0514459929*b);
const double m = std::cbrt(.2119034982*r + .6806995451*g + .1073969566*b);
const double s = std::cbrt(.0883024619*r + .2817188376*g + .6299787005*b);
return {float(.2104542553*l + .793617785*m - .0040720468*s),
float(1.9779984951*l - 2.428592205*m + .4505937099*s),
float(.0259040371*l + .7827717662*m - .808675766*s)};
}
QColor nextColour(const QList<QColor> &used)
{
if (used.isEmpty()) return QColor("#3daee9");
QList<QVector3D> positions;
for (const auto &colour : used) positions.append(perceptual(colour));
QColor best; double distance = -1;
for (int h = 0; h < 360; h += 3) for (int s : {160, 210, 255}) for (int v : {185, 220, 250}) {
const auto candidate = QColor::fromHsv(h, s, v);
const auto p = perceptual(candidate);
if (p.x() < .58 || p.x() > .82) continue; // Readable on light and dark backgrounds.
double nearest = 10;
for (const auto &other : positions) nearest = std::min(nearest, double((p - other).lengthSquared()));
if (nearest > distance) { distance = nearest; best = candidate; }
}
return best;
}
}
AxisTicks AxisTicks::covering(double minimum, double maximum, int intervals)
{
if (maximum <= minimum) maximum = minimum + 1;
const double step = niceStep((maximum - minimum) / std::max(1, intervals));
return {std::floor(minimum / step) * step, std::ceil(maximum / step) * step, step};
}
double timeTickStep(double spanMs, int intervals)
{
const double target = spanMs / 1000 / std::max(1, intervals);
for (double seconds : {1., 2., 5., 10., 15., 30., 60., 120., 300., 600., 900., 1800., 3600., 7200., 10800., 21600., 43200., 86400.})
if (seconds >= target) return seconds * 1000;
return niceStep(target / 86400) * 86400000;
}
QString ageLabel(double milliseconds)
{
const qint64 seconds = qRound64(milliseconds / 1000);
if (!seconds) return "Now";
if (seconds % 3600 == 0) return QString::number(seconds / 3600) + "h";
if (seconds % 60 == 0) return QString::number(seconds / 60) + "min";
return QString::number(seconds) + "s";
}
Chart::Chart(const QString &unit, QWidget *parent) : QWidget(parent), m_unit(unit)
{
setMinimumHeight(175);
setMouseTracking(true);
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
}
void Chart::addSeries(const QString &id, const QString &name, const QString &unit)
{
m_series.insert(id, {QColor(),
name.isEmpty() ? id : name, unit.isEmpty() ? m_unit : unit, {}});
}
void Chart::setSelected(const QString &id, bool selected)
{
if (!m_series.contains(id) || selected == m_selected.contains(id)) return;
if (selected) {
QList<QColor> used;
for (const auto &key : m_selected) used.append(m_series[key].color);
m_series[id].color = nextColour(used);
m_selected.insert(id);
} else m_selected.remove(id);
update();
}
QColor Chart::color(const QString &id) const { return m_series.value(id).color; }
QVector<QPointF> Chart::history(const QString &id) const
{
QVector<QPointF> points;
for (const auto &point : m_series.value(id).points) {
if (!points.isEmpty() && (point.x() - points.last().x() > m_interval * 3 || crossesSleep(points.last().x(), point.x())))
points.append({point.x(), NAN});
points.append(point);
}
return points;
}
void Chart::sample(const QMap<QString, double> &values, int intervalMs, qint64 now)
{
m_interval = intervalMs;
m_sampleTime = now;
double oldest = now;
for (auto it = m_series.begin(); it != m_series.end(); ++it) {
it->points.append({double(now), values.value(it.key(), std::numeric_limits<double>::quiet_NaN())});
if (it->points.size() > 600) it->points.remove(0, it->points.size() - 600);
oldest = std::min(oldest, it->points.first().x());
}
m_bands.removeIf([oldest](const Band &band) { return band.end && band.end < oldest; });
update();
}
void Chart::setPowerState(bool sleeping, std::optional<bool> plugged, qint64 now)
{
// Charger state cannot be observed during suspend; don't extend AC bands through it.
const std::optional<BandType> next = sleeping ? std::optional(BandType::Sleep)
: plugged.value_or(false) ? std::optional(BandType::Plugged) : std::nullopt;
if (!m_bands.isEmpty() && !m_bands.last().end) {
if (next == m_bands.last().type) return;
m_bands.last().end = now;
}
if (next) m_bands.append({now, 0, *next});
update();
}
bool Chart::crossesSleep(double from, double to) const
{
for (const auto &band : m_bands)
if (band.type == BandType::Sleep && band.begin < to && (!band.end || band.end > from)) return true;
return false;
}
Chart::Frame Chart::frame() const
{
// Hover repaints must not move the data or open a gap after the latest sample.
const double now = m_sampleTime;
double first = now, low = 0, high = m_unit == "%" ? 100 : 1, low2 = 0, high2 = 1;
bool secondary = false;
const auto fm = fontMetrics();
int unitWidth = fm.horizontalAdvance(m_unit);
for (auto it = m_series.cbegin(); it != m_series.cend(); ++it) {
if (!it->points.isEmpty()) first = std::min(first, it->points.first().x());
if (!selected(it.key())) continue;
const bool right = it->unit != m_unit;
secondary |= right;
unitWidth = std::max(unitWidth, fm.horizontalAdvance(it->unit));
for (const auto &point : it->points) if (std::isfinite(point.y())) {
(right ? low2 : low) = std::min(right ? low2 : low, point.y());
(right ? high2 : high) = std::max(right ? high2 : high, point.y());
}
}
const int timeTextWidth = fm.horizontalAdvance("100min");
const int margin = std::max(fm.horizontalAdvance("99999") + 10, unitWidth + timeTextWidth / 2 + 8);
const QRectF area(margin, fm.height() / 2 + 4, width() - margin - (secondary ? margin : 25), height() - 2 * fm.height() - 20);
const int intervals = std::max(2, int(area.height()) / (fm.height() * 2));
return {area, std::min(first, now - 10000), now, AxisTicks::covering(low, high, intervals),
AxisTicks::covering(low2, high2, intervals), secondary};
}
QString Chart::readingAt(qint64 time) const
{
QStringList lines{QDateTime::fromMSecsSinceEpoch(time).toString("ddd HH:mm:ss")};
bool sleeping = false;
for (const auto &band : m_bands) if (time >= band.begin && (!band.end || time < band.end)) {
sleeping |= band.type == BandType::Sleep;
lines.append(band.type == BandType::Sleep ? "Asleep · charger state unknown" : "Charger connected");
}
for (auto it = m_series.cbegin(); it != m_series.cend(); ++it) {
if (!selected(it.key())) continue;
const auto &points = it->points;
auto next = std::lower_bound(points.cbegin(), points.cend(), double(time), [](const QPointF &p, double t) { return p.x() < t; });
const QPointF *closest = next != points.cend() ? &*next : nullptr;
if (next != points.cbegin() && (!closest || time - (next - 1)->x() < closest->x() - time)) closest = &*(next - 1);
const bool valid = !sleeping && closest && std::abs(closest->x() - time) <= m_interval * 1.5
&& std::isfinite(closest->y()) && !crossesSleep(std::min(closest->x(), double(time)), std::max(closest->x(), double(time)));
lines.append(it->name + ": " + (valid ? number(closest->y()) + " " + it->unit : ""));
}
return lines.join('\n');
}
void Chart::mouseMoveEvent(QMouseEvent *event)
{
const auto f = frame();
if (!f.area.contains(event->position())) { setHoverTime(-1); Q_EMIT hovered(-1); QToolTip::hideText(); return; }
const qint64 time = qRound64(f.first + (event->position().x() - f.area.left()) / f.area.width() * (f.last - f.first));
setHoverTime(time); Q_EMIT hovered(time);
QToolTip::showText(event->globalPosition().toPoint() + QPoint(12, 16), readingAt(time), this);
}
void Chart::leaveEvent(QEvent *event) { setHoverTime(-1); Q_EMIT hovered(-1); QToolTip::hideText(); QWidget::leaveEvent(event); }
void Chart::paintEvent(QPaintEvent *)
{
QPainter p(this); p.setRenderHint(QPainter::Antialiasing);
const auto f = frame(); const auto area = f.area;
if (area.width() <= 0 || area.height() <= 0) return;
const auto x = [&](double time) { return area.left() + (time - f.first) / (f.last - f.first) * area.width(); };
const auto y = [&](double value, const AxisTicks &axis) { return area.bottom() - (value - axis.minimum) / (axis.maximum - axis.minimum) * area.height(); };
const auto text = palette().color(QPalette::Text);
QColor grid = text; grid.setAlpha(70);
const bool dark = palette().color(QPalette::Window).lightness() < 128;
p.save(); p.setClipRect(area);
for (const auto &band : m_bands) {
const QColor shade = band.type == BandType::Sleep ? (dark ? QColor(95, 130, 160, 65) : QColor(105, 140, 175, 45))
: (dark ? QColor(210, 95, 100, 40) : QColor(210, 70, 80, 30));
const double left = x(band.begin), right = x(band.end ? band.end : f.last);
p.fillRect(QRectF(left, area.top(), right - left, area.height()), shade);
p.setPen(QColor(shade.red(), shade.green(), shade.blue(), 130));
p.drawLine(QPointF(left, area.top()), QPointF(left, area.bottom()));
p.drawLine(QPointF(right, area.top()), QPointF(right, area.bottom()));
}
p.restore();
const int h = fontMetrics().height();
auto axis = [&](const AxisTicks &ticks, bool right) {
for (double value = ticks.minimum; value <= ticks.maximum + ticks.step / 2; value += ticks.step) {
const double pos = y(value, ticks);
p.setPen(QPen(grid, 1, right ? Qt::DotLine : Qt::SolidLine));
p.drawLine(QPointF(area.left(), pos), QPointF(area.right(), pos));
p.setPen(text);
p.drawLine(QPointF(right ? area.right() : area.left() - 6, pos), QPointF(right ? area.right() + 6 : area.left(), pos));
p.drawText(QRectF(right ? area.right() + 7 : 0, pos - h / 2., area.left() - 8, h),
(right ? Qt::AlignLeft : Qt::AlignRight) | Qt::AlignVCenter, number(value));
}
};
axis(f.left, false); if (f.secondary) axis(f.right, true);
const int labelWidth = fontMetrics().horizontalAdvance("100min") + 24;
// Clear the time label's text, not its wider tick-spacing rectangle.
const double unitGap = fontMetrics().horizontalAdvance("100min") / 2. + 4;
p.drawText(QRectF(0, area.bottom() + 7, area.left() - unitGap, h), Qt::AlignRight, m_unit);
if (f.secondary) {
for (auto it = m_series.cbegin(); it != m_series.cend(); ++it) if (selected(it.key()) && it->unit != m_unit) {
p.drawText(QRectF(area.right() + unitGap, area.bottom() + 7, width() - area.right() - unitGap, h), Qt::AlignLeft, it->unit); break;
}
}
const double step = timeTickStep(f.last - f.first, std::max(1, int(area.width()) / labelWidth));
for (double age = 0; age <= f.last - f.first; age += step) {
const double pos = x(f.last - age);
p.setPen(grid); p.drawLine(QPointF(pos, area.top()), QPointF(pos, area.bottom()));
p.setPen(text); p.drawLine(QPointF(pos, area.bottom()), QPointF(pos, area.bottom() + 6));
p.setPen(text); p.drawText(QRectF(pos - labelWidth / 2., area.bottom() + 7, labelWidth, h), Qt::AlignHCenter, ageLabel(age));
}
p.save(); p.setClipRect(area.adjusted(-1, -1, 1, 1));
bool hasValues = false;
for (auto it = m_series.cbegin(); it != m_series.cend(); ++it) {
if (!selected(it.key())) continue;
QPainterPath line; bool connected = false; double previous = 0;
for (const auto &point : it->points) {
if (!std::isfinite(point.y())) { connected = false; continue; }
hasValues = true;
const QPointF position(x(point.x()), y(point.y(), it->unit == m_unit ? f.left : f.right));
if (connected && point.x() - previous <= m_interval * 3 && !crossesSleep(previous, point.x())) line.lineTo(position);
else line.moveTo(position);
connected = true; previous = point.x();
}
p.setPen(QPen(it->color, 2)); p.drawPath(line);
}
p.restore();
if (m_hoverTime && *m_hoverTime >= f.first && *m_hoverTime <= f.last) {
p.setPen(QPen(text, 1, Qt::DashLine));
p.drawLine(QPointF(x(*m_hoverTime), area.top()), QPointF(x(*m_hoverTime), area.bottom()));
}
if (!hasValues) { p.setPen(text); p.drawText(area, Qt::AlignCenter, "No selected readings available"); }
}
+52
View File
@@ -0,0 +1,52 @@
// SPDX-License-Identifier: MIT
#pragma once
#include <QWidget>
#include <QDateTime>
#include <QMap>
#include <QSet>
#include <optional>
struct AxisTicks {
double minimum, maximum, step;
static AxisTicks covering(double minimum, double maximum, int intervals);
};
double timeTickStep(double spanMs, int intervals);
QString ageLabel(double milliseconds);
class Chart : public QWidget {
Q_OBJECT
public:
explicit Chart(const QString &unit, QWidget *parent = nullptr);
void addSeries(const QString &id, const QString &name = {}, const QString &unit = {});
void setSelected(const QString &id, bool selected);
bool selected(const QString &id) const { return m_selected.contains(id); }
void sample(const QMap<QString, double> &values, int intervalMs,
qint64 now = QDateTime::currentMSecsSinceEpoch());
void setPowerState(bool sleeping, std::optional<bool> plugged,
qint64 now = QDateTime::currentMSecsSinceEpoch());
QString readingAt(qint64 time) const;
QColor color(const QString &id) const;
QVector<QPointF> history(const QString &id) const;
void setHoverTime(qint64 time) { m_hoverTime = time < 0 ? std::nullopt : std::optional<qint64>(time); update(); }
std::optional<qint64> hoverTime() const { return m_hoverTime; }
Q_SIGNALS:
void hovered(qint64 time);
protected:
void paintEvent(QPaintEvent *) override;
void mouseMoveEvent(QMouseEvent *event) override;
void leaveEvent(QEvent *event) override;
private:
struct Series { QColor color; QString name, unit; QVector<QPointF> points; };
enum class BandType { Sleep, Plugged };
struct Band { qint64 begin, end; BandType type; };
struct Frame { QRectF area; double first, last; AxisTicks left, right; bool secondary; };
Frame frame() const;
bool crossesSleep(double from, double to) const;
QMap<QString, Series> m_series;
QSet<QString> m_selected;
QVector<Band> m_bands;
QString m_unit;
int m_interval = 1000;
qint64 m_sampleTime = QDateTime::currentMSecsSinceEpoch();
std::optional<qint64> m_hoverTime;
};
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: MIT
#include "colorbutton.h"
#include <QStylePainter>
#include <QStyleOptionButton>
void ColorButton::setColor(const QColor &color)
{
m_color = color;
setText(color.name() + (color.alpha() < 255 ? QString(" (%1% opacity)").arg(qRound(color.alphaF() * 100)) : QString()));
setToolTip("Swatch shows the RGB colour without transparency; opacity is shown separately.");
updateGeometry(); update();
}
QSize ColorButton::sizeHint() const
{
return QPushButton::sizeHint().expandedTo(QSize(fontMetrics().horizontalAdvance(text()) + 52, 30));
}
void ColorButton::paintEvent(QPaintEvent *)
{
QStylePainter p(this); QStyleOptionButton option; initStyleOption(&option);
option.text.clear(); p.drawControl(QStyle::CE_PushButton, option);
const QRect contents = style()->subElementRect(QStyle::SE_PushButtonContents, &option, this);
const QRect swatch(contents.left() + 6, contents.center().y() - 8, 22, 16);
QColor rgb = m_color; rgb.setAlpha(255);
p.fillRect(swatch, rgb); p.setPen(palette().color(QPalette::Mid)); p.drawRect(swatch);
p.setPen(palette().color(QPalette::ButtonText));
p.drawText(contents.adjusted(36, 0, -6, 0), Qt::AlignLeft | Qt::AlignVCenter, text());
}
+16
View File
@@ -0,0 +1,16 @@
// SPDX-License-Identifier: MIT
#pragma once
#include <QPushButton>
class ColorButton : public QPushButton {
Q_OBJECT
public:
explicit ColorButton(QWidget *parent = nullptr) : QPushButton(parent) {}
void setColor(const QColor &color);
QColor color() const { return m_color; }
QSize sizeHint() const override;
protected:
void paintEvent(QPaintEvent *) override;
private:
QColor m_color;
};
+163
View File
@@ -0,0 +1,163 @@
// SPDX-License-Identifier: MIT
#include "cpu.h"
#include <QDir>
#include <QFile>
#include <QJsonDocument>
#include <QRegularExpression>
#include <algorithm>
#include <cmath>
QStringList cpuOptions(const QString &attribute, const QString &sys)
{
const QDir root(sys + "/devices/system/cpu/cpufreq");
QStringList result;
bool first = true;
for (const auto &entry : root.entryList({"policy*"}, QDir::Dirs | QDir::NoDotAndDotDot)) {
const auto options = readText(root.filePath(entry + "/" + attribute)).split(QRegularExpression("\\s+"), Qt::SkipEmptyParts);
if (first) { result = options; first = false; }
else result.removeIf([&](const auto &option) { return !options.contains(option); });
}
return result;
}
QVariantMap normalizeCpuConfig(const QVariantMap &input)
{
if (input.isEmpty()) return {};
const bool legacy = input.contains("enabled"), enabled = input.value("enabled").toBool();
auto profile = [&](const QVariantMap &p) {
QVariantMap result{{"frequencyOverride", legacy ? enabled : p.value("frequencyOverride", false).toBool()},
{"minimum", p.value("minimum", 400)}, {"maximum", p.value("maximum", 5000)},
{"governor", legacy && !enabled ? "auto" : p.value("governor", "auto")},
{"preference", legacy && !enabled ? "auto" : p.value("preference", "auto")}};
if (p.contains("bounds")) result["bounds"] = p["bounds"];
return result;
};
QVariantMap result{{"separate", input.value("separate").toBool()}, {"battery", profile(input.value("battery").toMap())}};
if (result["separate"].toBool()) result["ac"] = profile(input.value("ac").toMap());
return result;
}
bool hasCpuOverrides(const QVariantMap &config)
{
for (const auto &key : {QString("battery"), QString("ac")}) {
if (key == "ac" && !config.value("separate").toBool()) continue;
const auto p = config.value(key).toMap();
for (const auto &bound : p.value("bounds").toMap()) if (bound.toMap().value("frequencyOverride").toBool()) return true;
if (p.value("frequencyOverride").toBool() || p.value("governor", "auto") != "auto" || p.value("preference", "auto") != "auto") return true;
}
return false;
}
QString effectiveCpuGovernor(const QString &requested, const QString &preference, const QString &driver)
{
return driver == "intel_pstate" && requested == "performance" && preference != "performance" && preference != "0"
? QString("powersave") : requested;
}
QString validateCpuProfile(const QVariantMap &profile, const QString &sys)
{
if (profile.contains("bounds")) {
const auto groups = cpuPolicyGroups(sys);
const auto bounds = profile["bounds"].toMap();
for (auto it = bounds.cbegin(); it != bounds.cend(); ++it) {
const auto p = it.value().toMap();
if (!p.value("frequencyOverride").toBool()) continue;
if (!groups.contains(it.key())) return "CPU core groups unavailable; refresh the settings.";
const auto limits = cpuLimits(sys, it.key());
bool a, b; const double lo = p["minimum"].toDouble(&a), hi = p["maximum"].toDouble(&b);
if (limits.isEmpty() || !a || !b || !std::isfinite(lo) || !std::isfinite(hi) || lo != std::round(lo) || hi != std::round(hi)
|| lo < limits["low"].toDouble() || hi > limits["high"].toDouble() || lo > hi) return "Invalid core-group frequency range.";
}
}
if (profile.value("frequencyOverride").toBool()) {
const auto limits = cpuLimits(sys);
bool minOk, maxOk;
const double minimum = profile.value("minimum").toDouble(&minOk), maximum = profile.value("maximum").toDouble(&maxOk);
if (limits.isEmpty() || !minOk || !maxOk || !std::isfinite(minimum) || !std::isfinite(maximum)
|| minimum != std::round(minimum) || maximum != std::round(maximum)
|| minimum < limits["low"].toInt() || maximum > limits["high"].toInt() || minimum > maximum)
return "Invalid CPU frequency range.";
}
const QString governor = profile.value("governor", "auto").toString(), preference = profile.value("preference", "auto").toString();
if (governor != "auto" && !cpuOptions("scaling_available_governors", sys).contains(governor)) return "Unsupported CPU governor.";
if (preference != "auto" && !cpuOptions("energy_performance_available_preferences", sys).contains(preference)) return "Unsupported energy preference.";
return {};
}
QString validateCpuConfig(const QVariantMap &config, const QString &sys)
{
if (!config.contains("separate") || !config.contains("battery")) return "Incomplete CPU configuration.";
auto error = validateCpuProfile(config.value("battery").toMap(), sys);
if (error.isEmpty() && config.value("separate").toBool()) {
if (!config.contains("ac")) return "Missing AC CPU profile.";
error = validateCpuProfile(config.value("ac").toMap(), sys);
}
return error;
}
QVariantMap cpuProfile(const QVariantMap &config, bool ac)
{
return config.value(ac && config.value("separate").toBool() ? "ac" : "battery").toMap();
}
QString applyCpuProfile(const QVariantMap &profile, const QString &sys)
{
auto error = validateCpuProfile(profile, sys);
if (!error.isEmpty()) return error;
const QString requested = profile.value("governor", "auto").toString(), preference = profile.value("preference", "auto").toString();
if (requested != "auto" || preference != "auto") {
const QDir root(sys + "/devices/system/cpu/cpufreq");
const auto policies = root.entryList({"policy*"}, QDir::Dirs | QDir::NoDotAndDotDot);
if (policies.isEmpty()) return "CPU policies unavailable.";
QVector<QPair<QString, QString>> writes;
for (const auto &entry : policies) {
const QString path = root.filePath(entry + "/");
const QString currentGovernor = readText(path + "scaling_governor");
const QString currentPreference = readText(path + "energy_performance_preference");
const QString driver = readText(path + "scaling_driver");
if (currentGovernor.isEmpty() || currentPreference.isEmpty() || driver.isEmpty()) return "CPU policy readings unavailable.";
const QString desiredPreference = preference == "auto" ? currentPreference : preference;
const QString governor = effectiveCpuGovernor(requested, desiredPreference, driver);
if (driver == "intel_pstate" && governor == "auto" && currentGovernor == "performance"
&& preference != "auto" && preference != "performance")
return "The current Performance governor blocks this energy preference. Select Powersave or leave energy preference on Auto.";
// Auto never writes its attribute. Explicit governor choices may have kernel EPP side effects.
if (governor != "auto" && governor != currentGovernor) writes.append({path + "scaling_governor", governor});
if (preference != "auto") writes.append({path + "energy_performance_preference", preference});
}
for (const auto &[path, value] : writes) {
if (readText(path) == value) continue;
QFile file(path); const auto data = value.toUtf8() + '\n';
if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size() || !file.flush())
return "CPU settings may be partially applied: " + path + ": " + file.errorString();
}
}
if (profile.contains("bounds")) {
const auto bounds = profile["bounds"].toMap();
for (auto it = bounds.cbegin(); it != bounds.cend(); ++it) {
const auto p = it.value().toMap();
if (!p.value("frequencyOverride").toBool()) continue;
const auto error = setCpuLimits(p["minimum"].toInt(), p["maximum"].toInt(), sys, it.key());
if (!error.isEmpty()) return error;
}
return {};
}
return profile.value("frequencyOverride").toBool()
? setCpuLimits(profile["minimum"].toInt(), profile["maximum"].toInt(), sys) : QString();
}
std::optional<bool> onAcPower(const QString &sys)
{
const QDir root(sys + "/class/power_supply");
bool known = false;
for (const auto &entry : root.entryList(QDir::Dirs | QDir::NoDotAndDotDot)) {
if (readText(root.filePath(entry + "/type")) == "Battery") continue;
const auto online = readNumber(root.filePath(entry + "/online"));
if (online) { known = true; if (*online > 0) return true; }
}
return known ? std::optional<bool>(false) : std::nullopt;
}
QVariantMap readCpuConfig(QString &error)
{
QFile file(cpuConfigPath);
if (!file.exists()) return {};
if (!file.open(QIODevice::ReadOnly)) { error = file.errorString(); return {}; }
QJsonParseError parse;
const auto document = QJsonDocument::fromJson(file.readAll(), &parse);
if (parse.error != QJsonParseError::NoError || !document.isObject()) {
error = "Invalid saved CPU configuration: " + parse.errorString(); return {};
}
return normalizeCpuConfig(document.toVariant().toMap());
}
+15
View File
@@ -0,0 +1,15 @@
// SPDX-License-Identifier: MIT
#pragma once
#include "hardware.h"
inline const QString cpuConfigPath = QStringLiteral("/etc/framework-laptop-tools/cpu.json");
QStringList cpuOptions(const QString &attribute, const QString &sys = QStringLiteral("/sys"));
QString validateCpuProfile(const QVariantMap &profile, const QString &sys = QStringLiteral("/sys"));
QString validateCpuConfig(const QVariantMap &config, const QString &sys = QStringLiteral("/sys"));
QVariantMap cpuProfile(const QVariantMap &config, bool ac);
QVariantMap normalizeCpuConfig(const QVariantMap &input);
bool hasCpuOverrides(const QVariantMap &config);
QString effectiveCpuGovernor(const QString &requested, const QString &preference, const QString &driver);
QString applyCpuProfile(const QVariantMap &profile, const QString &sys = QStringLiteral("/sys"));
std::optional<bool> onAcPower(const QString &sys = QStringLiteral("/sys"));
QVariantMap readCpuConfig(QString &error);
+163
View File
@@ -0,0 +1,163 @@
// SPDX-License-Identifier: MIT
#include "cpupage.h"
#include <QFormLayout>
#include <QHBoxLayout>
#include <QVBoxLayout>
#include <QPushButton>
QVariantMap CpuPage::Editor::value() const
{
QVariantMap result{{"frequencyOverride", frequency->isChecked() && bounds.isEmpty()}, {"minimum", minimum->value()}, {"maximum", maximum->value()},
{"governor", governor->currentText()}, {"preference", preference->currentText()}};
if (!bounds.isEmpty()) {
QVariantMap groups;
for (auto it = bounds.cbegin(); it != bounds.cend(); ++it) groups[it.key()] = QVariantMap{
{"frequencyOverride", it->enabled->isChecked()}, {"minimum", it->minimum->value()}, {"maximum", it->maximum->value()}};
result["bounds"] = groups;
}
return result;
}
void CpuPage::Editor::setValue(const QVariantMap &value)
{
minimum->setValue(value["minimum"].toInt()); maximum->setValue(value["maximum"].toInt());
frequency->setChecked(value.value("frequencyOverride").toBool());
minimum->setEnabled(frequency->isChecked()); maximum->setEnabled(frequency->isChecked());
governor->setCurrentText(value.value("governor", "auto").toString()); preference->setCurrentText(value.value("preference", "auto").toString());
for (auto it = bounds.cbegin(); it != bounds.cend(); ++it) {
const auto p = value.contains("bounds") ? value["bounds"].toMap().value(it.key()).toMap() : value;
it->minimum->setValue(p.value("minimum", it->minimum->minimum()).toInt());
it->maximum->setValue(p.value("maximum", it->maximum->maximum()).toInt());
it->enabled->setChecked(p.value("frequencyOverride").toBool());
it->minimum->setEnabled(it->enabled->isChecked()); it->maximum->setEnabled(it->enabled->isChecked());
}
}
CpuPage::Editor CpuPage::makeEditor(const QString &name)
{
Editor editor{new QGroupBox(name), new QCheckBox("Override frequency bounds"),
new ValueControl, new ValueControl, new QComboBox, new QComboBox, new QLabel};
editor.frequency->setObjectName("overrideFrequency");
editor.governor->setObjectName("cpuGovernor"); editor.preference->setObjectName("cpuPreference");
editor.warning->setWordWrap(true); editor.warning->setObjectName("governorWarning");
auto *layout = new QFormLayout(editor.group);
const auto limits = cpuLimits();
layout->addRow(editor.frequency);
editor.frequency->setToolTip("When unchecked, this tool does not write frequency limits. Existing limits are left unchanged.");
for (auto *box : {editor.minimum, editor.maximum}) {
box->setRange(limits.value("low", 400).toInt(), limits.value("high", 5000).toInt());
box->setSuffix(" MHz"); box->setSingleStep(100);
connect(box, &ValueControl::valueChanged, this, [this] { changed(); });
}
editor.governor->addItem("auto"); editor.preference->addItem("auto");
editor.governor->addItems(cpuOptions("scaling_available_governors"));
editor.preference->addItems(cpuOptions("energy_performance_available_preferences"));
for (auto *combo : {editor.governor, editor.preference}) {
combo->setToolTip("Auto leaves this setting to Linux or other controllers; it does not restore a preset. Save and apply commits changes.");
connect(combo, &QComboBox::currentTextChanged, this, [this] { changed(); });
}
layout->addRow("Minimum frequency:", editor.minimum); layout->addRow("Maximum frequency:", editor.maximum);
const auto groups = cpuPolicyGroups();
if (!groups.isEmpty()) {
layout->setRowVisible(editor.frequency, false); layout->setRowVisible(editor.minimum, false); layout->setRowVisible(editor.maximum, false);
for (const QString &key : {QString("p"), QString("e")}) {
Editor::Bounds b{new QCheckBox(key == "p" ? "Override P-core frequency bounds" : "Override E-core frequency bounds"), new ValueControl, new ValueControl};
const auto limits = cpuLimits("/sys", key);
for (auto *box : {b.minimum, b.maximum}) {
box->setRange(limits["low"].toInt(), limits["high"].toInt()); box->setSuffix(" MHz"); box->setSingleStep(100);
connect(box, &ValueControl::valueChanged, this, [this] { changed(); });
}
b.enabled->setObjectName("overrideFrequency_" + key);
layout->addRow(b.enabled); layout->addRow("Minimum frequency:", b.minimum); layout->addRow("Maximum frequency:", b.maximum);
connect(b.enabled, &QCheckBox::toggled, this, [this, b](bool enabled) { b.minimum->setEnabled(enabled); b.maximum->setEnabled(enabled); changed(); });
editor.bounds.insert(key, b);
}
}
layout->addRow("Energy preference:", editor.preference);
auto *row = new QWidget; auto *line = new QHBoxLayout(row); line->setContentsMargins(0, 0, 0, 0);
editor.governor->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
line->addWidget(editor.governor); line->addWidget(editor.warning, 1); line->addStretch();
layout->addRow("CPU governor:", row);
connect(editor.frequency, &QCheckBox::toggled, this, [this, editor](bool enabled) {
editor.minimum->setEnabled(enabled); editor.maximum->setEnabled(enabled); changed();
});
return editor;
}
CpuPage::CpuPage(QWidget *parent) : QWidget(parent)
{
auto *layout = new QVBoxLayout(this);
m_status = new QLabel; m_status->setWordWrap(true); layout->addWidget(m_status);
m_separate = new QCheckBox("Use separate profiles for battery power and AC");
m_separate->setObjectName("separateCpuProfiles"); layout->addWidget(m_separate);
m_separate->setToolTip("Splitting copies settings to both sides. Joining keeps the battery settings, committed only with Save and apply.");
auto *columns = new QHBoxLayout;
m_battery = makeEditor("CPU settings"); m_ac = makeEditor("AC power");
m_battery.group->setObjectName("batteryCpuProfile"); m_ac.group->setObjectName("acCpuProfile");
columns->addWidget(m_battery.group, 1); columns->addWidget(m_ac.group, 1); layout->addLayout(columns);
layout->addStretch();
connect(m_separate, &QCheckBox::toggled, this, &CpuPage::split);
load({}, true);
}
void CpuPage::changed()
{
if (!m_loading) {
// Joining hides the AC draft; it must survive refresh until saved or undone.
const auto savedAc = m_saved.value(m_saved.value("separate").toBool() ? "ac" : "battery").toMap();
m_dirty = draft() != m_saved || (m_hasAcDraft && !m_separate->isChecked() && m_ac.value() != savedAc);
refreshStatus(); Q_EMIT draftChanged();
}
}
void CpuPage::split(bool separate)
{
if (separate && !m_hasAcDraft) { m_ac.setValue(m_battery.value()); m_hasAcDraft = true; }
m_ac.group->setVisible(separate); m_battery.group->setTitle(separate ? "Battery power" : "CPU settings");
changed();
}
QVariantMap CpuPage::draft() const
{
QVariantMap config{{"separate", m_separate->isChecked()}, {"battery", m_battery.value()}};
if (m_separate->isChecked()) config["ac"] = m_ac.value();
return config;
}
void CpuPage::load(const QVariantMap &config, bool force)
{
if (m_dirty && !force) return;
m_loading = true;
auto saved = normalizeCpuConfig(config);
if (saved.isEmpty()) {
const auto limits = cpuLimits();
saved = {{"separate", false}, {"battery", QVariantMap{{"frequencyOverride", false},
{"minimum", limits.value("min", 400)}, {"maximum", limits.value("max", 5000)},
{"governor", "auto"}, {"preference", "auto"}}}};
}
m_saved = saved;
m_battery.setValue(saved["battery"].toMap());
m_hasAcDraft = saved["separate"].toBool();
if (m_hasAcDraft) m_ac.setValue(saved["ac"].toMap());
else m_ac.setValue(saved["battery"].toMap());
m_separate->setChecked(m_hasAcDraft); split(m_hasAcDraft);
m_saved = draft(); m_loading = false; m_dirty = false; refreshStatus(); Q_EMIT draftChanged();
}
void CpuPage::refreshStatus()
{
const auto limits = cpuLimits(); const auto ac = onAcPower();
m_status->setText(limits.isEmpty() ? "Live CPU readings unavailable." :
QString("Live: %1\nEnergy preference: %3\nGovernor: %2\nFrequency bounds: %4%5 MHz (highest across policies); hardware maximum: %6 MHz.%7")
.arg(ac ? (*ac ? "AC power" : "battery power") : "power source unknown", limits["governor"].toString(), limits["preference"].toString())
.arg(limits["min"].toInt()).arg(limits["max"].toInt()).arg(limits["high"].toInt())
.arg(m_dirty ? "\nUnsaved changes." : ""));
auto warning = [&](Editor &editor, bool active) {
QString text;
const auto p = editor.value();
const QString governor = p["governor"].toString(), pref = p["preference"].toString();
if (readText("/sys/devices/system/cpu/cpufreq/policy0/scaling_driver") == "intel_pstate") {
if (governor == "performance" && pref == "auto")
text = "⚠ Requires performance EPP; may fall back to powersave.";
else if (governor == "performance" && pref != "performance")
text = "⚠ Requires performance EPP; will fall back to powersave.";
else if (governor == "auto" && pref != "auto" && pref != "performance" && active && limits["governor"] == "performance")
text = "⚠ Current Performance governor blocks this EPP. Select Powersave or leave EPP on Auto.";
}
editor.warning->setText(text); editor.warning->setVisible(!text.isEmpty());
};
warning(m_battery, !m_separate->isChecked() || (ac && !*ac));
warning(m_ac, m_separate->isChecked() && ac && *ac);
}
+42
View File
@@ -0,0 +1,42 @@
// SPDX-License-Identifier: MIT
#pragma once
#include "cpu.h"
#include <QWidget>
#include <QCheckBox>
#include <QComboBox>
#include "valuecontrol.h"
#include <QGroupBox>
#include <QLabel>
class CpuPage : public QWidget {
Q_OBJECT
public:
explicit CpuPage(QWidget *parent = nullptr);
QVariantMap draft() const;
void load(const QVariantMap &config, bool force = false);
bool dirty() const { return m_dirty; }
void refreshStatus();
void undo() { load(m_saved, true); }
Q_SIGNALS:
void draftChanged();
private:
struct Editor {
struct Bounds { QCheckBox *enabled; ValueControl *minimum, *maximum; };
QGroupBox *group;
QCheckBox *frequency;
ValueControl *minimum, *maximum;
QComboBox *governor, *preference;
QLabel *warning;
QMap<QString, Bounds> bounds;
QVariantMap value() const;
void setValue(const QVariantMap &value);
};
Editor makeEditor(const QString &name);
void split(bool separate);
void changed();
QCheckBox *m_separate;
Editor m_battery, m_ac;
QLabel *m_status;
QVariantMap m_saved;
bool m_dirty = false, m_loading = false, m_hasAcDraft = false;
};
+67
View File
@@ -0,0 +1,67 @@
// SPDX-License-Identifier: MIT
#include "cpu.h"
#include <QCoreApplication>
#include <QDBusConnection>
#include <QDebug>
#include <QTimer>
#include <QDir>
#include <systemd/sd-daemon.h>
class CpuWorker : public QObject {
Q_OBJECT
public:
CpuWorker() {
m_apply.setSingleShot(true); m_apply.setInterval(2000);
connect(&m_apply, &QTimer::timeout, this, &CpuWorker::apply);
connect(&m_poll, &QTimer::timeout, this, [this] {
const auto ac = onAcPower();
if (ac != m_ac) { m_ac = ac; m_apply.start(); }
const auto profile = cpuProfile(m_config, ac.value_or(false));
if (profile.value("governor") == "performance" && profile.value("preference") == "auto") {
const auto now = preferences();
if (now != m_preferences) { m_preferences = now; m_apply.start(); }
}
sd_notify(0, "WATCHDOG=1");
});
auto bus = QDBusConnection::systemBus();
bus.connect("com.redhat.tuned", "/Tuned", "com.redhat.tuned.control", "profile_changed", this, SLOT(profileChanged(QString,bool,QString)));
bus.connect("org.freedesktop.login1", "/org/freedesktop/login1", "org.freedesktop.login1.Manager", "PrepareForSleep", this, SLOT(sleepChanged(bool)));
m_poll.start(2000); m_apply.start();
}
private Q_SLOTS:
void profileChanged(const QString &, bool success, const QString &) { if (success) m_apply.start(); }
void sleepChanged(bool sleeping) { if (!sleeping) m_apply.start(); }
void apply() {
QString error;
const auto config = readCpuConfig(error);
if (error.isEmpty()) error = validateCpuConfig(config);
const auto ac = onAcPower();
if (error.isEmpty() && !ac && config.value("separate").toBool()) error = "Cannot determine the power source.";
if (error.isEmpty()) error = applyCpuProfile(cpuProfile(config, ac.value_or(false)));
if (!error.isEmpty()) { qCritical().noquote() << error; QCoreApplication::exit(1); return; }
m_ac = ac;
m_config = config; m_preferences = preferences();
qInfo() << "Applied saved CPU profile:" << (ac.value_or(false) ? "AC" : "battery/shared");
sd_notify(0, "READY=1\nWATCHDOG=1");
}
private:
QStringList preferences() const {
const QDir root("/sys/devices/system/cpu/cpufreq"); QStringList values;
for (const auto &entry : root.entryList({"policy*"}, QDir::Dirs | QDir::NoDotAndDotDot))
values.append(readText(root.filePath(entry + "/energy_performance_preference")));
return values;
}
QTimer m_poll, m_apply;
std::optional<bool> m_ac;
QVariantMap m_config;
QStringList m_preferences;
};
int main(int argc, char **argv)
{
QCoreApplication app(argc, argv);
auto error = compatibilityError();
if (!error.isEmpty()) { qCritical().noquote() << error; return 1; }
CpuWorker worker;
return app.exec();
}
#include "cpuservice.moc"
+98
View File
@@ -0,0 +1,98 @@
// SPDX-License-Identifier: MIT
#include "fan.h"
#include <QFile>
#include <QFileInfo>
#include <QJsonDocument>
#include <cmath>
QVariantMap readFanConfig(QString &error)
{
QFile file(QFile::exists(fanConfigPath) ? fanConfigPath : savedFanConfigPath);
if (!file.exists()) return {};
if (!file.open(QIODevice::ReadOnly)) { error = file.errorString(); return {}; }
QJsonParseError parse;
const auto doc = QJsonDocument::fromJson(file.readAll(), &parse);
auto config = doc.toVariant().toMap();
error = parse.error == QJsonParseError::NoError ? validateFan(config) : "Invalid fan configuration.";
return config;
}
QVector<double> fanTemperatures(const QVariantMap &config)
{
if (!config.contains("temperatures")) return {40, 55, 70, 85};
QVector<double> result;
for (const auto &value : config["temperatures"].toList()) {
bool ok; const double n = value.toDouble(&ok);
if (!ok || !std::isfinite(n)) return {};
result.append(n);
}
return result;
}
QString validateFan(const QVariantMap &config)
{
const auto mode = config.value("mode").toString();
if (mode == "auto") return {};
if (mode != "manual" && mode != "curve") return "Unknown fan mode.";
const auto values = mode == "manual" ? QVariantList{config.value("duty")} : config.value("curve").toList();
if (values.size() != (mode == "manual" ? 1 : 4)) return "A fan curve needs four points.";
double previous = 0;
for (const auto &value : values) {
bool ok;
const double n = value.toDouble(&ok);
if (!ok || !std::isfinite(n) || n < previous || n > 100) return "Fan percentages must be non-decreasing and between 0 and 100.";
previous = n;
}
if (mode == "curve" && previous != 100) return "The final curve point must be 100%.";
if (mode == "curve") {
const auto temperatures = fanTemperatures(config);
if (temperatures.size() != 4) return "A fan curve needs four temperatures.";
double previousTemperature = 19;
for (const double t : temperatures) {
if (t <= previousTemperature || t > 85) return "Curve temperatures must increase, from 20 to 85 °C.";
previousTemperature = t;
}
}
return {};
}
QString restoreFan()
{
if (!compatibilityError().isEmpty()) return compatibilityError();
const auto path = ecHwmon();
if (path.isEmpty()) return "EC fan interface unavailable.";
return writeNumber(path + "/pwm1_enable", 2);
}
QString updateFan(const QVariantMap &config, double &lastDuty, const QString &sys)
{
const auto validation = validateFan(config);
if (!validation.isEmpty()) return validation;
if (config.value("mode") == "auto") return "Auto does not need a fan worker.";
const auto path = ecHwmon(sys);
if (path.isEmpty()) return "EC fan interface disappeared.";
if (readNumber(path + "/fan1_fault") != std::optional<double>(0)) return "Fan reports a fault; returning to Auto.";
double hottest = 0;
for (int i = 1; i <= 5; ++i) {
const QString stem = path + "/temp" + QString::number(i);
const auto value = readNumber(stem + "_input");
const auto fault = readNumber(stem + "_fault");
const auto limit = readNumber(stem + "_crit");
if (!value || !fault || *fault != 0 || !limit || *limit <= 0 || *value < 0 || *value >= *limit - 3000)
return "Temperature unavailable or near a firmware thermal limit; returning to Auto.";
hottest = std::max(hottest, *value / 1000);
}
if (hottest >= 90) return "Temperature reached 90 °C; returning to Auto.";
double duty = config.value("duty").toDouble();
if (config.value("mode") == "curve") {
QVector<double> points;
for (const auto &value : config.value("curve").toList()) points.append(value.toDouble());
duty = curveDuty(hottest, points, fanTemperatures(config));
// Increase immediately; lower gradually to avoid audible oscillation.
if (lastDuty >= 0 && duty < lastDuty) duty = std::max(duty, lastDuty - 2);
}
if (readNumber(path + "/pwm1_enable") != std::optional<double>(1)) {
const auto error = writeNumber(path + "/pwm1_enable", 1);
if (!error.isEmpty()) return error;
}
const auto error = writeNumber(path + "/pwm1", qRound(duty * 255 / 100));
if (error.isEmpty()) lastDuty = duty;
return error;
}
+10
View File
@@ -0,0 +1,10 @@
// SPDX-License-Identifier: MIT
#pragma once
#include "hardware.h"
QString validateFan(const QVariantMap &config);
QString restoreFan();
QString updateFan(const QVariantMap &config, double &lastDuty, const QString &sys = QStringLiteral("/sys"));
inline const QString fanConfigPath = QStringLiteral("/run/framework-laptop-tools/fan.json");
inline const QString savedFanConfigPath = QStringLiteral("/etc/framework-laptop-tools/fan.json");
QVariantMap readFanConfig(QString &error);
QVector<double> fanTemperatures(const QVariantMap &config);
+43
View File
@@ -0,0 +1,43 @@
// SPDX-License-Identifier: MIT
#include "fan.h"
#include <QCoreApplication>
#include <QFile>
#include <QJsonDocument>
#include <QTimer>
#include <QDebug>
#include <QProcess>
#include <systemd/sd-daemon.h>
int main(int argc, char **argv)
{
QCoreApplication app(argc, argv);
if (app.arguments().contains("--resume")) {
if (!QFile::exists(savedFanConfigPath)) return 0;
if (QProcess::execute("/usr/bin/systemctl", {"--quiet", "is-enabled", "framework-laptop-tools-fan.service"}) != 0) return 0;
return QProcess::execute("/usr/bin/systemctl", {"--no-block", "start", "framework-laptop-tools-fan.service"});
}
if (app.arguments().contains("--restore")) {
if (!QFile::exists(fanConfigPath) && !QFile::exists(savedFanConfigPath)) return 0;
const auto error = restoreFan();
if (!error.isEmpty()) { qCritical().noquote() << error; return 1; }
if (QFile::exists(fanConfigPath) && !QFile::remove(fanConfigPath)) { qCritical() << "Cannot remove fan runtime configuration"; return 1; }
return 0;
}
const auto compatible = compatibilityError();
if (!compatible.isEmpty()) { qCritical().noquote() << compatible; return 1; }
double lastDuty = -1;
bool ready = false;
QTimer timer;
auto tick = [&] {
QString error;
const auto config = readFanConfig(error);
if (error.isEmpty()) error = updateFan(config, lastDuty);
if (!error.isEmpty()) { qCritical().noquote() << error; app.exit(1); return; }
if (!ready) { sd_notify(0, "READY=1"); ready = true; }
sd_notify(0, "WATCHDOG=1");
};
QObject::connect(&timer, &QTimer::timeout, &app, tick);
timer.start(1000);
QTimer::singleShot(0, &app, tick);
return app.exec();
}
+306
View File
@@ -0,0 +1,306 @@
// SPDX-License-Identifier: MIT
#include "hardware.h"
#include <QDir>
#include <QDirIterator>
#include <QFile>
#include <QFileInfo>
#include <QSet>
#include <QtEndian>
#include <algorithm>
#include <cmath>
#include <cstring>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <unistd.h>
QString readText(const QString &path)
{
QFile file(path);
return file.open(QIODevice::ReadOnly) ? QString::fromUtf8(file.readAll()).trimmed() : QString();
}
std::optional<double> readNumber(const QString &path)
{
bool ok;
const double n = readText(path).toDouble(&ok);
return ok && std::isfinite(n) ? std::optional<double>(n) : std::nullopt;
}
QString compatibilityError(const QString &sys)
{
if (readText(sys + "/class/dmi/id/sys_vendor") != "Framework"
|| readText(sys + "/class/dmi/id/product_name") != "Laptop 13 Pro (Intel Core Ultra Series 3)")
return "Supported hardware: Framework Laptop 13 Pro (Intel Core Ultra Series 3). Other models have not been validated.";
return {};
}
QString ecHwmon(const QString &sys)
{
const QDir dir(sys + "/class/hwmon");
for (const QString &name : dir.entryList({"hwmon*"}, QDir::Dirs | QDir::NoDotAndDotDot))
if (readText(dir.filePath(name + "/name")) == "cros_ec") return dir.filePath(name);
return {};
}
QVector<Sensor> temperatureSensors(const QString &sys)
{
QVector<Sensor> sensors;
const QMap<QString, QString> friendly{{"local_f75397@4c", "Mainboard (EC)"},
{"cpu_f75303@4d", "CPU area (EC)"}, {"battery_temp@b", "Battery (EC)"},
{"ddr_f75303@4d", "Memory area (EC)"}, {"peci-temp", "CPU (PECI)"}};
const QDir root(sys + "/class/hwmon");
for (const auto &entry : root.entryList({"hwmon*"}, QDir::Dirs | QDir::NoDotAndDotDot)) {
const QDir dir(root.filePath(entry));
const QString chip = readText(dir.filePath("name"));
for (const auto &file : dir.entryList({"temp*_input"}, QDir::Files)) {
const QString stem = file.chopped(6);
const QString raw = readText(dir.filePath(stem + "_label"));
QString label = friendly.value(raw);
bool primary = !label.isEmpty() && raw != "cpu_f75303@4d" && raw != "ddr_f75303@4d";
if (chip == "nvme") {
label = raw == "Composite" ? "NVMe (composite)" : "NVMe — " + raw;
primary = raw == "Composite";
} else if (chip == "spd5118") {
label = "Memory (SPD)";
primary = true;
} else if (chip == "coretemp") {
label = raw.startsWith("Package id") ? "CPU package " + raw.section(' ', -1) : "CPU " + raw.toLower();
}
if (label.isEmpty()) label = chip + "" + (raw.isEmpty() ? stem : raw);
// Retain raw identifiers in the ID/tooltip; don't invent sensor locations.
sensors.append({chip + "/" + (raw.isEmpty() ? stem : raw), label, dir.filePath(file), "°C", .001, primary});
}
}
return sensors;
}
QVector<Sensor> frequencySensors(const QString &sys)
{
QVector<Sensor> sensors;
sensors.append({"cpu", "CPU average (reported)", sys + "/devices/system/cpu/cpufreq", "MHz", .001});
const QDir drm(sys + "/class/drm");
for (const auto &card : drm.entryList({"card*"}, QDir::Dirs | QDir::NoDotAndDotDot)) {
QDirIterator files(drm.filePath(card + "/device"), {"act_freq"}, QDir::Files,
QDirIterator::Subdirectories);
while (files.hasNext()) {
const QString path = files.next();
const QString relative = path.mid(drm.filePath(card + "/device/").size());
sensors.append({card + "/" + relative, "GPU " + card + " " + relative.section('/', 1, 1), path, "MHz", 1});
}
}
return sensors;
}
std::optional<double> sensorValue(const Sensor &sensor)
{
if (sensor.id == "cpu") {
const QDir dir(sensor.path);
double total = 0;
int count = 0;
for (const auto &policy : dir.entryList({"policy*"}, QDir::Dirs | QDir::NoDotAndDotDot)) {
const auto n = readNumber(dir.filePath(policy + "/scaling_cur_freq"));
if (n) { total += *n; ++count; }
}
return count ? std::optional<double>(total / count * sensor.scale) : std::nullopt;
}
if (sensor.unit == "°C") {
const QString fault = sensor.path.chopped(6) + "_fault";
if (QFileInfo::exists(fault) && readNumber(fault) != std::optional<double>(0)) return {};
}
auto n = readNumber(sensor.path);
if (!n) return {};
*n *= sensor.scale;
if (sensor.unit == "°C" && (*n < -20 || *n > 150)) return {};
return n;
}
QVariantMap batteryStatus(const QString &sys)
{
const QDir root(sys + "/class/power_supply");
for (const auto &entry : root.entryList(QDir::Dirs | QDir::NoDotAndDotDot)) {
const QDir dir(root.filePath(entry));
if (readText(dir.filePath("type")) != "Battery") continue;
QVariantMap result{{"state", readText(dir.filePath("status"))}};
for (const auto &name : {"capacity", "cycle_count", "voltage_now", "voltage_min_design", "charge_now", "charge_full", "charge_full_design", "energy_now", "energy_full", "energy_full_design", "power_now", "current_now"}) {
const auto n = readNumber(dir.filePath(name));
if (n) result.insert(name, *n);
}
if (result.contains("power_now")) result["watts"] = std::abs(result["power_now"].toDouble()) / 1e6;
else if (result.contains("current_now") && result.contains("voltage_now"))
result["watts"] = std::abs(result["current_now"].toDouble() * result["voltage_now"].toDouble()) / 1e12;
if (result.contains("charge_full") && result["charge_full_design"].toDouble() > 0)
result["health"] = 100 * result["charge_full"].toDouble() / result["charge_full_design"].toDouble();
if (result.contains("energy_full")) {
result["fullMWh"] = result["energy_full"].toDouble() / 1000;
if (result["energy_full_design"].toDouble() > 0)
result["health"] = 100 * result["energy_full"].toDouble() / result["energy_full_design"].toDouble();
} else if (result.contains("charge_full") && result["voltage_min_design"].toDouble() > 0) {
result["fullMWh"] = result["charge_full"].toDouble() * result["voltage_min_design"].toDouble() / 1e9;
result["capacityEstimated"] = true;
}
return result;
}
return {};
}
std::optional<double> batteryRate(const QVariantMap &battery)
{
if (!battery.contains("watts")) return {};
const double watts = battery["watts"].toDouble();
if (battery["state"] == "Charging") return watts;
if (battery["state"] == "Discharging") return -watts;
if (battery["state"] == "Full" || battery["state"] == "Not charging") return 0.;
return {};
}
QMap<QString, QStringList> cpuPolicyGroups(const QString &sys)
{
QMap<QString, QSet<int>> cpus;
for (const auto &kind : {QString("core"), QString("atom")}) {
const auto list = readText(sys + "/bus/event_source/devices/cpu_" + kind + "/cpus");
for (const auto &range : list.split(',', Qt::SkipEmptyParts)) {
const auto ends = range.split('-'); bool a, b;
const int first = ends.first().toInt(&a), last = ends.last().toInt(&b);
if (!a || !b || first < 0 || last < first || last > 65535) return {};
for (int cpu = first; cpu <= last; ++cpu) cpus[kind].insert(cpu);
}
}
if (cpus["core"].isEmpty() || cpus["atom"].isEmpty()) return {};
QMap<QString, QStringList> groups;
const QDir root(sys + "/devices/system/cpu/cpufreq");
for (const auto &policy : root.entryList({"policy*"}, QDir::Dirs | QDir::NoDotAndDotDot)) {
const auto related = readText(root.filePath(policy + "/related_cpus")).simplified().split(' ', Qt::SkipEmptyParts);
QString type;
for (const auto &id : related) {
bool ok; const int cpu = id.toInt(&ok);
const QString candidate = cpus["core"].contains(cpu) ? "p" : cpus["atom"].contains(cpu) ? "e" : "";
if (!ok || candidate.isEmpty() || (!type.isEmpty() && type != candidate)) return {};
type = candidate;
}
if (type.isEmpty()) return {};
groups[type].append(policy);
}
return groups.size() == 2 ? groups : QMap<QString, QStringList>{};
}
QVariantMap cpuLimits(const QString &sys, const QString &group)
{
const QDir root(sys + "/devices/system/cpu/cpufreq");
const auto policies = group.isEmpty() ? root.entryList({"policy*"}, QDir::Dirs | QDir::NoDotAndDotDot) : cpuPolicyGroups(sys).value(group);
double low = 0, high = 0, min = 0, max = 0;
int count = 0, capped = 0, raised = 0;
for (const auto &entry : policies) {
auto a = readNumber(root.filePath(entry + "/cpuinfo_min_freq"));
auto b = readNumber(root.filePath(entry + "/cpuinfo_max_freq"));
auto c = readNumber(root.filePath(entry + "/scaling_min_freq"));
auto d = readNumber(root.filePath(entry + "/scaling_max_freq"));
if (!a || !b || !c || !d) return {};
low = std::max(low, *a); high = std::max(high, *b);
min = std::max(min, *c); max = std::max(max, *d); ++count;
if (*d < *b) ++capped;
if (*c > *a) ++raised;
}
if (!count) return {};
return {{"low", low / 1000}, {"high", high / 1000}, {"min", min / 1000}, {"max", max / 1000},
{"policies", count}, {"capped", capped}, {"raised", raised},
{"governor", readText(root.filePath("policy0/scaling_governor"))},
{"preference", readText(root.filePath("policy0/energy_performance_preference"))}};
}
QString writeNumber(const QString &path, qint64 value)
{
QFile file(path);
const QByteArray data = QByteArray::number(value) + '\n';
if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size() || !file.flush())
return path + ": " + file.errorString();
return {};
}
QString setCpuLimits(int minimumMHz, int maximumMHz, const QString &sys, const QString &group)
{
const auto limits = cpuLimits(sys, group);
if (limits.isEmpty() || minimumMHz < limits["low"].toInt() || maximumMHz > limits["high"].toInt() || minimumMHz > maximumMHz)
return "Invalid CPU frequency range.";
const QDir root(sys + "/devices/system/cpu/cpufreq");
const auto policies = group.isEmpty() ? root.entryList({"policy*"}, QDir::Dirs | QDir::NoDotAndDotDot) : cpuPolicyGroups(sys).value(group);
for (const auto &entry : policies) {
const QString path = root.filePath(entry);
const auto currentMax = readNumber(path + "/scaling_max_freq");
const auto hardwareMin = readNumber(path + "/cpuinfo_min_freq");
const auto hardwareMax = readNumber(path + "/cpuinfo_max_freq");
if (!currentMax || !hardwareMin || !hardwareMax) return "CPU policy disappeared; refresh and try again.";
const int policyMin = std::clamp(minimumMHz * 1000, int(*hardwareMin), int(*hardwareMax));
const int policyMax = std::clamp(maximumMHz * 1000, int(*hardwareMin), int(*hardwareMax));
const bool raiseFirst = policyMin > *currentMax;
const QString first = raiseFirst ? "/scaling_max_freq" : "/scaling_min_freq";
QString error = writeNumber(path + first, raiseFirst ? policyMax : policyMin);
if (error.isEmpty()) error = writeNumber(path + (raiseFirst ? "/scaling_min_freq" : "/scaling_max_freq"), raiseFirst ? policyMin : policyMax);
if (!error.isEmpty()) return "Some policies may already have changed. " + error;
}
return {};
}
double curveDuty(double temperature, const QVector<double> &duties, const QVector<double> &temperatures)
{
if (duties.size() != 4 || temperatures.size() != 4 || !std::isfinite(temperature)) return 100;
if (temperature <= temperatures.first()) return duties.first();
for (int i = 1; i < temperatures.size(); ++i) if (temperature <= temperatures[i])
return duties[i - 1] + (duties[i] - duties[i - 1]) * (temperature - temperatures[i - 1]) / (temperatures[i] - temperatures[i - 1]);
return duties.last();
}
namespace {
struct EcHeader { quint32 version, command, outsize, insize, result; };
struct EcPacket { EcHeader header; unsigned char data[32]; };
static_assert(sizeof(EcHeader) == 20);
QByteArray ecCommand(quint32 command, quint32 version, const QByteArray &request, int responseSize, QString &error)
{
EcPacket packet{{version, command, quint32(request.size()), quint32(responseSize), 0}, {0}};
if (request.size() > 32 || responseSize > 32) { error = "Invalid EC buffer size"; return {}; }
std::memcpy(packet.data, request.constData(), request.size());
const int fd = open("/dev/cros_ec", O_RDWR | O_CLOEXEC);
if (fd < 0) { error = QString::fromLocal8Bit(strerror(errno)); return {}; }
const int result = ioctl(fd, _IOWR(0xEC, 0, EcHeader), &packet);
const int savedErrno = errno;
close(fd);
if (result < 0 || packet.header.result || result != responseSize) {
error = QString("EC command 0x%1 failed (result %2, bytes %3): %4")
.arg(command, 0, 16).arg(packet.header.result).arg(result)
.arg(result < 0 ? QString::fromLocal8Bit(strerror(savedErrno)) : QString("unexpected response"));
return {};
}
return QByteArray(reinterpret_cast<char *>(packet.data), result);
}
}
QVariantMap firmwareStatus()
{
QVariantMap result;
QString error;
const auto led = ecCommand(0x3E0E, 1, QByteArray::fromHex("ffff"), 2, error);
if (error.isEmpty()) { result["powerBrightness"] = quint8(led[0]); result["powerAuto"] = quint8(led[1]) == 255; }
else result["powerError"] = error;
error.clear();
const auto charge = ecCommand(0x3E03, 0, QByteArray::fromHex("08ffff"), 2, error);
if (error.isEmpty()) {
const int limit = quint8(charge[0]) & 0x7f;
result["chargeLimit"] = limit == 0 || limit == 127 ? 100 : limit;
result["chargeOverride"] = (quint8(charge[0]) & 0x80) != 0;
}
else result["chargeError"] = error;
return result;
}
QString setFirmwareValue(const QString &operation, int value)
{
QString error;
if (operation == "powerBrightness" && value >= 1 && value <= 100) {
QByteArray request(2, 0); request[0] = char(value);
ecCommand(0x3E0E, 1, request, 0, error);
}
else if (operation == "powerAuto")
ecCommand(0x3E0E, 0, QByteArray::fromHex("ff00"), 0, error);
else if (operation == "chargeLimit" && value >= 50 && value <= 100) {
const auto current = ecCommand(0x3E03, 0, QByteArray::fromHex("08ffff"), 2, error);
if (error.isEmpty()) {
QByteArray request = QByteArray::fromHex("020000");
request[1] = char(value); request[2] = char(std::min<int>(quint8(current[1]), value));
ecCommand(0x3E03, 0, request, 0, error);
}
} else if (operation == "chargeWatts" && value >= 0 && value <= 75) {
const auto battery = batteryStatus();
const double voltage = battery.value("voltage_now").toDouble() / 1e6;
if (value != 0 && (voltage < 10 || voltage > 20)) return "Battery voltage unavailable or outside the supported range.";
// The EC accepts mA, not watts. Zero here means restore its default limit.
const quint32 milliamps = value == 0 ? 0xffffffff : quint32(value * 1000 / voltage);
QByteArray request(4, 0);
qToLittleEndian(milliamps, request.data());
ecCommand(0xA1, 0, request, 0, error);
} else return "Unsupported firmware setting or value.";
return error;
}
+28
View File
@@ -0,0 +1,28 @@
// SPDX-License-Identifier: MIT
#pragma once
#include <QString>
#include <QVariantMap>
#include <QVector>
#include <optional>
struct Sensor {
QString id, name, path, unit;
double scale = 1;
bool primary = false;
};
QString readText(const QString &path);
std::optional<double> readNumber(const QString &path);
QString compatibilityError(const QString &sys = QStringLiteral("/sys"));
QString ecHwmon(const QString &sys = QStringLiteral("/sys"));
QVector<Sensor> temperatureSensors(const QString &sys = QStringLiteral("/sys"));
QVector<Sensor> frequencySensors(const QString &sys = QStringLiteral("/sys"));
QVariantMap batteryStatus(const QString &sys = QStringLiteral("/sys"));
QMap<QString, QStringList> cpuPolicyGroups(const QString &sys = QStringLiteral("/sys"));
QVariantMap cpuLimits(const QString &sys = QStringLiteral("/sys"), const QString &group = {});
std::optional<double> sensorValue(const Sensor &sensor);
std::optional<double> batteryRate(const QVariantMap &battery);
double curveDuty(double temperature, const QVector<double> &duties, const QVector<double> &temperatures = {40, 55, 70, 85});
QString writeNumber(const QString &path, qint64 value);
QString setCpuLimits(int minimumMHz, int maximumMHz, const QString &sys = QStringLiteral("/sys"), const QString &group = {});
QVariantMap firmwareStatus();
QString setFirmwareValue(const QString &operation, int value);
+129
View File
@@ -0,0 +1,129 @@
// SPDX-License-Identifier: MIT
#include "hardware.h"
#include "fan.h"
#include "cpu.h"
#include <KAuth/ActionReply>
#include <KAuth/HelperSupport>
#include <QDir>
#include <QFile>
#include <QJsonDocument>
#include <QProcess>
#include <QSaveFile>
class Helper : public QObject {
Q_OBJECT
KAuth::ActionReply reply(const QString &error, const QVariantMap &data = {}) {
if (error.isEmpty()) { auto r = KAuth::ActionReply::SuccessReply(); r.setData(data); return r; }
auto r = KAuth::ActionReply::HelperErrorReply(); r.setErrorDescription(error); return r;
}
QString service(const QString &verb, const QString &unit = "framework-laptop-tools-fan.service") {
QProcess process;
process.start("/usr/bin/systemctl", {verb, unit});
if (!process.waitForFinished(25000)) { process.kill(); process.waitForFinished(); return "Service did not respond."; }
return process.exitCode() == 0 ? QString() : QString::fromUtf8(process.readAllStandardError());
}
public Q_SLOTS:
KAuth::ActionReply inspect(const QVariantMap &) {
const auto error = compatibilityError();
if (!error.isEmpty()) return reply(error);
auto data = firmwareStatus();
QString cpuError;
data["cpuConfig"] = readCpuConfig(cpuError);
if (!cpuError.isEmpty()) data["cpuError"] = cpuError;
const auto path = ecHwmon();
const auto mode = readNumber(path + "/pwm1_enable");
if (mode == std::optional<double>(2)) data["fanMode"] = "auto";
else if (mode == std::optional<double>(1)) {
data["fanMode"] = "manual";
const auto pwm = readNumber(path + "/pwm1");
if (pwm) data["fanDuty"] = qRound(*pwm * 100 / 255);
QString fanError;
const auto config = readFanConfig(fanError);
if (!fanError.isEmpty()) data["fanError"] = fanError;
if (fanError.isEmpty()) {
if (config.value("mode") == "curve") {
data["fanMode"] = "curve";
data["fanCurve"] = config["curve"];
QVariantList temperatures;
for (const double t : fanTemperatures(config)) temperatures.append(t);
data["fanTemperatures"] = temperatures;
}
}
}
data["fanPersistent"] = QFile::exists(savedFanConfigPath);
return reply({}, data);
}
KAuth::ActionReply configure(const QVariantMap &args) {
QString error = compatibilityError();
if (!error.isEmpty()) return reply(error);
const QString operation = args.value("operation").toString();
if (operation == "batch") {
const auto operations = args.value("operations").toList();
if (operations.isEmpty() || operations.size() > 6) return reply("Invalid settings batch.");
const QStringList allowed{"keyboard", "powerAuto", "powerBrightness", "chargeLimit", "chargeWatts", "fan", "cpuProfiles"};
for (const auto &entry : operations)
if (!allowed.contains(entry.toMap().value("operation").toString())) return reply("Unknown operation in settings batch.");
QVariantList completed;
for (const auto &entry : operations) {
const auto result = configure(entry.toMap());
if (result.failed()) return reply({}, {{"completed", completed}, {"applyError", result.errorDescription()}});
completed.append(entry);
}
return reply({}, {{"completed", completed}});
}
bool valid;
const double raw = args.value("value").toDouble(&valid);
const int value = valid && raw >= 0 && raw <= 10000 && raw == int(raw) ? int(raw) : -1;
if (operation == "keyboard" && value >= 0 && value <= 100)
error = writeNumber("/sys/class/leds/chromeos::kbd_backlight/brightness", value);
else if (operation == "cpuProfiles") {
const auto config = normalizeCpuConfig(args.value("config").toMap());
error = validateCpuConfig(config);
if (!error.isEmpty()) return reply(error);
if (!QDir().mkpath("/etc/framework-laptop-tools")) return reply("Cannot create CPU configuration directory.");
QSaveFile file(cpuConfigPath);
if (!file.open(QIODevice::WriteOnly)) return reply(file.errorString());
file.setPermissions(QFile::ReadOwner | QFile::WriteOwner);
const auto data = QJsonDocument::fromVariant(config).toJson();
if (file.write(data) != data.size() || !file.commit()) return reply(file.errorString());
const QString unit = "framework-laptop-tools-cpu.service";
if (hasCpuOverrides(config)) {
error = service("enable", unit);
if (error.isEmpty()) error = service("restart", unit);
} else {
error = service("disable", unit);
if (error.isEmpty()) error = service("stop", unit);
}
if (!error.isEmpty()) error = "CPU settings were saved, but applying them failed: " + error;
} else if (operation == "fan") {
error = validateFan(args);
if (!error.isEmpty()) return reply(error);
// Restore the old worker before replacing its configuration.
error = service("stop");
if (!error.isEmpty()) return reply(error);
if (QFile::exists(savedFanConfigPath) && !QFile::remove(savedFanConfigPath)) return reply("Cannot remove saved fan settings.");
const bool persistent = args.value("mode") != "auto";
error = service(persistent ? "enable" : "disable");
if (error.isEmpty()) error = service(persistent ? "enable" : "disable", "framework-laptop-tools-fan-resume.service");
if (!error.isEmpty()) return reply(error);
if (args.value("mode") == "auto") error = restoreFan();
else {
const QString path = persistent ? savedFanConfigPath : fanConfigPath;
if (!QDir().mkpath(QFileInfo(path).absolutePath())) error = "Cannot create fan settings directory.";
if (error.isEmpty()) {
QSaveFile file(path);
if (!file.open(QIODevice::WriteOnly)) return reply(file.errorString());
file.setPermissions(QFile::ReadOwner | QFile::WriteOwner);
const auto data = QJsonDocument::fromVariant(args).toJson(QJsonDocument::Compact);
if (file.write(data) != data.size() || !file.commit()) return reply(file.errorString());
error = service("start");
}
}
} else if (operation == "powerAuto") error = setFirmwareValue(operation, 0);
else if (value >= 0) error = setFirmwareValue(operation, value);
else error = "Unknown operation or invalid value.";
return reply(error);
}
};
KAUTH_HELPER_MAIN("se.ajpanton.frameworktools", Helper)
#include "helper.moc"
+52
View File
@@ -0,0 +1,52 @@
// SPDX-License-Identifier: MIT
#include "legend.h"
#include <QEvent>
Legend::Legend(Chart *chart, const QVector<Sensor> &sensors, QWidget *parent)
: QLabel(parent), m_chart(chart), m_sensors(sensors)
{
setWordWrap(true); setTextFormat(Qt::RichText);
setTextInteractionFlags(Qt::LinksAccessibleByMouse | Qt::LinksAccessibleByKeyboard);
setToolTip("Click a sensor to show or hide it. Hover over the graph for readings.");
connect(this, &QLabel::linkHovered, this, [this](const QString &link) {
bool valid; const int index = link.toInt(&valid);
setToolTip(valid && index >= 0 && index < m_sensors.size()
? m_sensors[index].name + "\n" + m_sensors[index].path + "\nClick to show or hide."
: "Click a sensor to show or hide it. Hover over the graph for readings.");
});
connect(this, &QLabel::linkActivated, this, [this](const QString &link) {
if (link == "extra") { Q_EMIT extraActivated(); return; }
bool valid; const int index = link.toInt(&valid);
if (!valid || index < 0 || index >= m_sensors.size()) return;
const QString id = m_sensors[index].id;
const bool enabled = !m_chart->selected(id);
m_chart->setSelected(id, enabled); refresh(); Q_EMIT selectionChanged(id, enabled);
});
refresh();
}
int Legend::enabledCount() const
{
int count = 0;
for (const auto &sensor : m_sensors) if (m_chart->selected(sensor.id)) ++count;
return count;
}
void Legend::refresh()
{
QStringList links;
for (int i = 0; i < m_sensors.size(); ++i) {
const auto &sensor = m_sensors[i]; const bool enabled = m_chart->selected(sensor.id);
const auto gray = palette().color(QPalette::Disabled, QPalette::WindowText);
const auto text = enabled ? palette().color(QPalette::WindowText) : gray;
const auto dot = enabled ? m_chart->color(sensor.id) : gray;
links.append(QString("<a href=\"%1\" style=\"color:%2;text-decoration:none\"><span style=\"color:%4\">●</span>&nbsp;<span style=\"text-decoration:%3\">%5</span></a>")
.arg(i).arg(text.name(), enabled ? "none" : "line-through", dot.name(), sensor.name.toHtmlEscaped().replace(' ', "&nbsp;")));
}
if (!m_extraLink.isEmpty()) links.append(QString("<a href=\"extra\" style=\"color:%1;text-decoration:none\">%2</a>")
.arg(palette().color(QPalette::WindowText).name(), m_extraLink.toHtmlEscaped().replace(' ', "&nbsp;")));
setText(links.join(" &nbsp; &nbsp; "));
}
void Legend::changeEvent(QEvent *event)
{
QLabel::changeEvent(event);
if (event->type() == QEvent::PaletteChange || event->type() == QEvent::ApplicationPaletteChange) refresh();
}
+24
View File
@@ -0,0 +1,24 @@
// SPDX-License-Identifier: MIT
#pragma once
#include "chart.h"
#include "hardware.h"
#include <QLabel>
// Rich-text links wrap naturally without widening the page for long sensor lists.
class Legend : public QLabel {
Q_OBJECT
public:
Legend(Chart *chart, const QVector<Sensor> &sensors, QWidget *parent = nullptr);
int enabledCount() const;
void setExtraLink(const QString &text) { m_extraLink = text; refresh(); }
Q_SIGNALS:
void selectionChanged(const QString &id, bool selected);
void extraActivated();
protected:
void changeEvent(QEvent *event) override;
private:
void refresh();
Chart *m_chart;
QVector<Sensor> m_sensors;
QString m_extraLink;
};
+35
View File
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: MIT
#include "window.h"
#include <QApplication>
#include <QDBusConnection>
#include <QDBusAbstractAdaptor>
#include <QDBusInterface>
class WindowAdaptor : public QDBusAbstractAdaptor {
Q_OBJECT
Q_CLASSINFO("D-Bus Interface", "se.ajpanton.FrameworkLaptopTools")
public:
explicit WindowAdaptor(Window *window) : QDBusAbstractAdaptor(window), m_window(window) {}
public Q_SLOTS:
void Show() { m_window->show(); m_window->raise(); m_window->activateWindow(); }
private:
Window *m_window;
};
int main(int argc, char **argv)
{
QApplication app(argc, argv);
app.setApplicationName("framework-laptop-tools");
app.setDesktopFileName("se.ajpanton.framework-laptop-tools");
auto bus = QDBusConnection::sessionBus();
if (!bus.registerService("se.ajpanton.FrameworkLaptopTools")) {
QDBusInterface existing("se.ajpanton.FrameworkLaptopTools", "/Window", "se.ajpanton.FrameworkLaptopTools");
const auto response = existing.call("Show");
return response.type() == QDBusMessage::ErrorMessage ? 1 : 0;
}
Window window;
new WindowAdaptor(&window);
bus.registerObject("/Window", &window, QDBusConnection::ExportAdaptors);
if (!app.arguments().contains("--tray") || !QSystemTrayIcon::isSystemTrayAvailable()) window.show();
return app.exec();
}
#include "main.moc"
+79
View File
@@ -0,0 +1,79 @@
// SPDX-License-Identifier: MIT
#include "tray.h"
#include <QPainter>
#include <QPainterPath>
#include <cmath>
#include <algorithm>
std::optional<double> CpuUsage::sample(const QString &procStat)
{
const auto fields = procStat.section('\n', 0, 0).simplified().split(' ');
if (fields.size() < 5 || fields.first() != "cpu") { reset(); return {}; }
Counters current{0, 0};
// guest/guest_nice are already included in user/nice; sum only through steal.
for (int i = 1; i < std::min(9, int(fields.size())); ++i) {
bool ok; const auto value = fields[i].toULongLong(&ok);
if (!ok) { reset(); return {}; }
current.total += value;
if (i == 4 || i == 5) current.idle += value;
}
const auto previous = m_previous; m_previous = current;
if (!previous || current.total <= previous->total || current.idle < previous->idle) return {};
const auto total = current.total - previous->total, idle = current.idle - previous->idle;
if (idle > total) return {};
return 100. * (total - idle) / total;
}
QRectF trayPlotRect(bool border)
{
// Two-pixel border; leave room for the graph's two-pixel stroke inside it.
return border ? QRectF(3, 3, 58, 58) : QRectF(1, 1, 62, 62);
}
QIcon telemetryIcon(bool graph, const QVector<QPointF> &values, const QString &unit, const TrayStyle &style)
{
QPixmap pixmap(64, 64); pixmap.fill(style.transparent ? Qt::transparent : style.backgroundColor);
QPainter p(&pixmap); p.setRenderHint(QPainter::Antialiasing);
if (style.border) { p.setPen(QPen(style.borderColor, 2)); p.drawRect(QRectF(1, 1, 62, 62)); }
const auto area = trayPlotRect(style.border);
const double latest = values.isEmpty() ? NAN : values.last().y();
p.setPen(style.lineColor);
if (graph && !values.isEmpty() && style.maximum > style.minimum) {
const double first = values.first().x(), span = std::max(1., values.last().x() - first);
const auto position = [&](const QPointF &point) {
const double fraction = std::clamp((point.y() - style.minimum) / (style.maximum - style.minimum), 0., 1.);
return QPointF(area.left() + (point.x() - first) / span * area.width(), area.bottom() - fraction * area.height());
};
const auto outside = [&](double y) { return y < style.minimum || y > style.maximum; };
const auto color = [&](bool out) { return out && style.overflowColor ? style.outsideColor : style.lineColor; };
for (int i = 1; i < values.size(); ++i) {
const auto a = values[i - 1], b = values[i];
if (!std::isfinite(a.y()) || !std::isfinite(b.y())) continue;
QVector<double> cuts{0, 1};
if (a.y() != b.y()) for (double boundary : {style.minimum, style.maximum}) {
const double t = (boundary - a.y()) / (b.y() - a.y());
if (t > 0 && t < 1) cuts.append(t);
}
std::sort(cuts.begin(), cuts.end());
for (int j = 1; j < cuts.size(); ++j) {
const bool out = outside(a.y() + (b.y() - a.y()) * (cuts[j - 1] + cuts[j]) / 2);
if (out && !style.clamp) continue;
const auto left = position(a + (b - a) * cuts[j - 1]), right = position(a + (b - a) * cuts[j]);
if (style.fill) {
QPainterPath fill; fill.moveTo(left); fill.lineTo(right);
fill.lineTo(right.x(), area.bottom()); fill.lineTo(left.x(), area.bottom()); fill.closeSubpath();
p.fillPath(fill, style.fillColor);
}
p.setPen(QPen(color(out), 2, Qt::SolidLine, Qt::FlatCap)); p.drawLine(left, right);
}
}
for (const auto &point : values) if (std::isfinite(point.y()) && (style.clamp || !outside(point.y()))) {
p.setPen(QPen(color(outside(point.y())), 2, Qt::SolidLine, Qt::RoundCap)); p.drawPoint(position(point));
}
} else {
const QString value = !std::isfinite(latest) ? "" : unit == "MHz"
? QString::number(latest / 1000, 'f', 1) : QString::number(std::round(latest), 'f', 0);
QFont font; font.setBold(true); font.setPixelSize(42);
while (QFontMetrics(font).horizontalAdvance(value) > area.width() && font.pixelSize() > 12) font.setPixelSize(font.pixelSize() - 1);
p.setFont(font); p.drawText(area, Qt::AlignCenter, value);
}
return QIcon(pixmap);
}
+22
View File
@@ -0,0 +1,22 @@
// SPDX-License-Identifier: MIT
#pragma once
#include <QIcon>
#include <QPalette>
#include <QVector>
#include <optional>
class CpuUsage {
public:
std::optional<double> sample(const QString &procStat);
void reset() { m_previous.reset(); }
private:
struct Counters { quint64 total, idle; };
std::optional<Counters> m_previous;
};
struct TrayStyle {
bool border = true, transparent = false, fill = true, clamp = true, overflowColor = true;
QColor borderColor, backgroundColor, fillColor, lineColor, outsideColor;
double minimum = 0, maximum = 100;
};
QRectF trayPlotRect(bool border);
QIcon telemetryIcon(bool graph, const QVector<QPointF> &values, const QString &unit, const TrayStyle &style);
+145
View File
@@ -0,0 +1,145 @@
// SPDX-License-Identifier: MIT
#include "traypage.h"
#include "colorbutton.h"
#include <QVBoxLayout>
#include <QPushButton>
#include <QColorDialog>
#include <QLabel>
#include <QSignalBlocker>
TrayPage::TrayPage(QSettings &settings, const QVector<Sensor> &sensors, QWidget *parent)
: QWidget(parent), m_sensors(sensors)
{
for (const auto &key : settings.allKeys()) if (key.startsWith("tray/")) m_values[key] = settings.value(key);
auto *layout = new QVBoxLayout(this);
auto *form = new QFormLayout; form->setFieldGrowthPolicy(QFormLayout::FieldsStayAtSizeHint); layout->addLayout(form);
m_mode = new QComboBox; m_mode->setObjectName("trayDisplayMode");
m_mode->addItem("Application icon", "icon"); m_mode->addItem("History graph", "graph"); m_mode->addItem("Number", "number");
m_metric = new QComboBox; m_metric->setObjectName("trayMetric");
for (const auto &sensor : sensors) m_metric->addItem(sensor.name + " (" + sensor.unit + ")", sensor.id);
m_mode->setCurrentIndex(std::max(0, m_mode->findData(m_values.value("tray/mode", "icon"))));
m_metric->setCurrentIndex(std::max(0, m_metric->findData(m_values.value("tray/metric", "cpu-usage"))));
form->addRow("Display:", m_mode); form->addRow("Reading:", m_metric);
auto *appearance = new QWidget; auto *colors = new QFormLayout(appearance); colors->setContentsMargins(0, 0, 0, 0);
colors->setFieldGrowthPolicy(QFormLayout::FieldsStayAtSizeHint);
auto colorRow = [&](QFormLayout *target, const QString &label, const QString &key, const QColor &fallback, bool alpha = false) {
auto *button = new ColorButton; button->setObjectName(key);
auto refresh = [this, button, key, fallback] {
const QColor color(m_values.value("tray/" + key, fallback.name(QColor::HexArgb)).toString());
button->setColor(color);
};
m_values.insert("tray/" + key, m_values.value("tray/" + key, fallback.name(QColor::HexArgb)));
m_reload.append(refresh);
refresh(); target->addRow(label, button);
connect(button, &QPushButton::clicked, this, [this, key, fallback, alpha, refresh] {
const auto chosen = QColorDialog::getColor(QColor(m_values.value("tray/" + key, fallback.name(QColor::HexArgb)).toString()),
this, "Choose colour", alpha ? QColorDialog::ShowAlphaChannel : QColorDialog::ColorDialogOptions{});
if (!chosen.isValid()) return;
m_values.insert("tray/" + key, chosen.name(QColor::HexArgb)); refresh(); if (!m_loading) Q_EMIT settingsChanged();
});
return button;
};
auto check = [&](QFormLayout *target, const QString &label, const QString &key, bool fallback) {
auto *box = new QCheckBox(label); box->setObjectName(key); box->setChecked(m_values.value("tray/" + key, fallback).toBool());
m_values.insert("tray/" + key, box->isChecked());
m_reload.append([this, box, key, fallback] { box->setChecked(m_values.value("tray/" + key, fallback).toBool()); });
target->addRow(box);
connect(box, &QCheckBox::toggled, this, [this, key](bool value) { m_values.insert("tray/" + key, value); if (!m_loading) Q_EMIT settingsChanged(); });
return box;
};
m_border = check(colors, "Show border", "border", true);
auto *borderColor = colorRow(colors, "Border colour:", "borderColor", palette().color(QPalette::WindowText));
borderColor->setEnabled(m_border->isChecked()); connect(m_border, &QCheckBox::toggled, borderColor, &QWidget::setEnabled);
m_transparent = check(colors, "Transparent background", "transparent", false);
auto *background = colorRow(colors, "Background colour:", "backgroundColor", palette().color(QPalette::Window));
background->setDisabled(m_transparent->isChecked()); connect(m_transparent, &QCheckBox::toggled, background, &QWidget::setDisabled);
colorRow(colors, "Line / number colour:", "lineColor", QColor("#3daee9"));
layout->addWidget(appearance);
m_graphSettings = new QWidget; m_scaleLayout = new QFormLayout(m_graphSettings); m_scaleLayout->setContentsMargins(0, 0, 0, 0);
m_scaleLayout->setFieldGrowthPolicy(QFormLayout::FieldsStayAtSizeHint);
m_minimum = new QDoubleSpinBox; m_minimum->setObjectName("trayMinimum");
m_maximum = new QDoubleSpinBox; m_maximum->setObjectName("trayMaximum");
m_scaleLayout->addRow("Graph minimum:", m_minimum); m_scaleLayout->addRow("Graph maximum:", m_maximum);
m_fill = check(m_scaleLayout, "Fill below the line", "fill", true);
auto *fillColor = colorRow(m_scaleLayout, "Fill colour:", "fillColor", QColor(61, 174, 233, 80), true);
fillColor->setEnabled(m_fill->isChecked()); connect(m_fill, &QCheckBox::toggled, fillColor, &QWidget::setEnabled);
m_outside = new QComboBox; m_outside->setObjectName("trayOutside");
m_outside->addItem("Draw along the inner edge", "clamp"); m_outside->addItem("Do not draw outside readings", "hide");
m_outside->setCurrentIndex(std::max(0, m_outside->findData(m_values.value("tray/outside", "clamp"))));
m_scaleLayout->addRow("Outside graph range:", m_outside);
m_overflowColor = check(m_scaleLayout, "Use a different colour outside the range", "overflowColor", true);
auto *outsideColor = colorRow(m_scaleLayout, "Outside-range colour:", "outsideColor", QColor("#f67400"));
auto syncOutside = [this, outsideColor] {
m_overflowColor->setEnabled(m_outside->currentData() == "clamp");
outsideColor->setEnabled(m_overflowColor->isEnabled() && m_overflowColor->isChecked());
};
connect(m_overflowColor, &QCheckBox::toggled, this, syncOutside);
connect(m_outside, &QComboBox::currentIndexChanged, this, [this, syncOutside] {
m_values.insert("tray/outside", m_outside->currentData()); syncOutside(); if (!m_loading) Q_EMIT settingsChanged();
});
syncOutside(); layout->addWidget(m_graphSettings);
auto syncMode = [this, appearance] {
const bool telemetry = mode() != "icon";
m_metric->setEnabled(telemetry); appearance->setVisible(telemetry); m_graphSettings->setVisible(mode() == "graph");
};
connect(m_mode, &QComboBox::currentIndexChanged, this, [this, syncMode] {
m_values.insert("tray/mode", m_mode->currentData()); syncMode(); if (!m_loading) Q_EMIT settingsChanged();
});
connect(m_metric, &QComboBox::currentIndexChanged, this, [this] {
m_values.insert("tray/metric", m_metric->currentData()); loadScale(); if (!m_loading) Q_EMIT settingsChanged();
});
connect(m_minimum, &QDoubleSpinBox::valueChanged, this, [this](double value) {
m_maximum->setMinimum(value + 1); m_values.insert(scaleKey("minimum"), value); if (!m_loading) Q_EMIT settingsChanged();
});
connect(m_maximum, &QDoubleSpinBox::valueChanged, this, [this](double value) {
m_minimum->setMaximum(value - 1); m_values.insert(scaleKey("maximum"), value); if (!m_loading) Q_EMIT settingsChanged();
});
loadScale(); syncMode();
m_values.insert("tray/mode", m_mode->currentData());
m_values.insert("tray/metric", m_metric->currentData());
m_values.insert("tray/outside", m_outside->currentData());
m_reload.append([this, syncMode, syncOutside] {
m_mode->setCurrentIndex(std::max(0, m_mode->findData(m_values.value("tray/mode", "icon"))));
m_metric->setCurrentIndex(std::max(0, m_metric->findData(m_values.value("tray/metric", "cpu-usage"))));
m_outside->setCurrentIndex(std::max(0, m_outside->findData(m_values.value("tray/outside", "clamp"))));
syncMode(); syncOutside();
});
layout->addStretch();
}
void TrayPage::load(const QVariantMap &values)
{
m_loading = true; m_values = values;
for (const auto &reload : m_reload) reload();
m_values = values; loadScale(); m_loading = false;
}
QString TrayPage::scaleKey(const QString &suffix) const { return "tray/scales/" + metric().id + "/" + suffix; }
void TrayPage::loadScale()
{
const QSignalBlocker a(m_minimum), b(m_maximum);
const QString unit = metric().unit;
const bool frequency = unit == "MHz", temperature = unit == "°C", rate = unit == "W";
m_scaleLayout->setRowVisible(m_minimum, temperature || rate); m_scaleLayout->setRowVisible(m_maximum, temperature || frequency || rate);
for (auto *spin : {m_minimum, m_maximum}) { spin->setDecimals(0); spin->setRange(rate ? -300 : temperature ? -50 : 0, frequency ? 20000 : 300); spin->setSuffix(" " + unit); }
double maximum = frequency ? (metric().id == "cpu" ? cpuLimits().value("high", 5000).toDouble() : 3000) : rate ? 75 : 100;
const double low = m_values.value(scaleKey("minimum"), rate ? -75 : 0).toDouble();
maximum = m_values.value(scaleKey("maximum"), maximum).toDouble();
m_minimum->setValue(low); m_maximum->setMinimum(m_minimum->value() + 1); m_maximum->setValue(maximum);
m_minimum->setMaximum(m_maximum->value() - 1);
}
TrayStyle TrayPage::iconStyle() const
{
auto color = [this](const QString &key, const QColor &fallback) {
const QColor value(m_values.value("tray/" + key, fallback.name(QColor::HexArgb)).toString());
return value.isValid() ? value : fallback;
};
TrayStyle s;
s.border = m_border->isChecked(); s.transparent = m_transparent->isChecked(); s.fill = m_fill->isChecked();
s.clamp = m_outside->currentData() == "clamp"; s.overflowColor = m_overflowColor->isChecked();
s.borderColor = color("borderColor", palette().color(QPalette::WindowText));
s.backgroundColor = color("backgroundColor", palette().color(QPalette::Window));
s.lineColor = color("lineColor", QColor("#3daee9")); s.fillColor = color("fillColor", QColor(61, 174, 233, 80));
s.outsideColor = color("outsideColor", QColor("#f67400"));
if (metric().unit == "MHz") s.maximum = m_maximum->value();
else if (metric().unit == "°C" || metric().unit == "W") { s.minimum = m_minimum->value(); s.maximum = m_maximum->value(); }
return s;
}
+36
View File
@@ -0,0 +1,36 @@
// SPDX-License-Identifier: MIT
#pragma once
#include "hardware.h"
#include "tray.h"
#include <QWidget>
#include <QSettings>
#include <QComboBox>
#include <QDoubleSpinBox>
#include <QCheckBox>
#include <QFormLayout>
#include <functional>
class TrayPage : public QWidget {
Q_OBJECT
public:
TrayPage(QSettings &settings, const QVector<Sensor> &sensors, QWidget *parent = nullptr);
QString mode() const { return m_mode->currentData().toString(); }
Sensor metric() const { return m_sensors[m_metric->currentIndex()]; }
TrayStyle iconStyle() const;
QVariantMap draft() const { return m_values; }
void load(const QVariantMap &values);
Q_SIGNALS:
void settingsChanged();
private:
void loadScale();
QString scaleKey(const QString &suffix) const;
QVariantMap m_values;
QList<std::function<void()>> m_reload;
bool m_loading = false;
QVector<Sensor> m_sensors;
QComboBox *m_mode, *m_metric, *m_outside;
QDoubleSpinBox *m_minimum, *m_maximum;
QFormLayout *m_scaleLayout;
QWidget *m_graphSettings;
QCheckBox *m_border, *m_transparent, *m_fill, *m_overflowColor;
};
@@ -0,0 +1,42 @@
// SPDX-License-Identifier: MIT
#include "valuecontrol.h"
#include <QHBoxLayout>
#include <QSignalBlocker>
#include <algorithm>
ValueControl::ValueControl(QWidget *parent) : QWidget(parent),
m_slider(new QSlider(Qt::Horizontal, this)), m_number(new QSpinBox(this))
{
auto *layout = new QHBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->addWidget(m_slider, 1); layout->addWidget(m_number);
m_slider->setMinimumWidth(100);
setFocusProxy(m_number);
connect(m_slider, &QSlider::valueChanged, m_number, &QSpinBox::setValue);
connect(m_number, &QSpinBox::valueChanged, m_slider, &QSlider::setValue);
connect(m_number, &QSpinBox::valueChanged, this, &ValueControl::valueChanged);
setRange(0, 100);
}
void ValueControl::setRange(int minimum, int maximum)
{
// Block the slider while changing its range so its old value cannot clamp
// the number field before both controls have the new bounds.
const QSignalBlocker block(m_slider);
m_slider->setRange(minimum, maximum);
m_number->setRange(minimum, maximum);
m_slider->setValue(m_number->value());
m_slider->setPageStep(std::max(1, (maximum - minimum) / 10));
updateRangeHint();
}
void ValueControl::setSuffix(const QString &suffix)
{
m_number->setSuffix(suffix); updateRangeHint();
}
void ValueControl::setSingleStep(int step)
{
m_slider->setSingleStep(step); m_number->setSingleStep(step);
}
void ValueControl::updateRangeHint()
{
m_slider->setToolTip(QString("%1%2%3").arg(m_number->minimum()).arg(m_number->maximum()).arg(m_number->suffix()));
}
+26
View File
@@ -0,0 +1,26 @@
// SPDX-License-Identifier: MIT
#pragma once
#include <QWidget>
#include <QSlider>
#include <QSpinBox>
// One bounded value, adjustable by dragging or by entering an exact number.
class ValueControl : public QWidget {
Q_OBJECT
public:
explicit ValueControl(QWidget *parent = nullptr);
int value() const { return m_number->value(); }
int minimum() const { return m_number->minimum(); }
int maximum() const { return m_number->maximum(); }
void setValue(int value) { m_number->setValue(value); }
void setRange(int minimum, int maximum);
void setSuffix(const QString &suffix);
void setSingleStep(int step);
void setSpecialValueText(const QString &text) { m_number->setSpecialValueText(text); }
Q_SIGNALS:
void valueChanged(int value);
private:
void updateRangeHint();
QSlider *m_slider;
QSpinBox *m_number;
};
+548
View File
@@ -0,0 +1,548 @@
// SPDX-License-Identifier: MIT
#include "window.h"
#include "legend.h"
#include "fan.h"
#include <QMessageBox>
#include <KAuth/Action>
#include <KAuth/ExecuteJob>
#include <KJob>
#include <QApplication>
#include <QCloseEvent>
#include <QDBusInterface>
#include <QDBusReply>
#include <QFormLayout>
#include <QGroupBox>
#include <QMenu>
#include <QPushButton>
#include <QScrollArea>
#include <QTabWidget>
#include <QVBoxLayout>
#include <QFile>
#include <QSaveFile>
#include <QStandardPaths>
#include <QDir>
#include <QFileInfo>
#include <cmath>
namespace {
QLabel *note(const QString &text) { auto *label = new QLabel(text); label->setWordWrap(true); return label; }
ValueControl *spin(int minimum, int maximum, const QString &suffix) {
auto *box = new ValueControl; box->setRange(minimum, maximum); box->setSuffix(suffix); return box;
}
QString duration(double seconds) {
if (!std::isfinite(seconds) || seconds < 60) return "less than a minute";
return QString("%1 h %2 min").arg(int(seconds) / 3600).arg(int(seconds) / 60 % 60);
}
}
Window::Window() : m_settings("fedora-tools", "framework-laptop-tools"),
m_temperatures(temperatureSensors()), m_frequencies(frequencySensors())
{
for (const auto &entry : QList<QPair<QString, QList<int>>>{
{"sampling/fast", {1000, 500, 2000, 4000}},
{"sampling/battery", {30000, 15000, 60000, 120000}}}) {
if (!entry.second.contains(m_settings.value(entry.first).toInt()))
m_settings.setValue(entry.first, entry.second.first());
}
setWindowTitle("Framework Laptop Tools"); setWindowIcon(QIcon::fromTheme("computer-laptop")); resize(1000, 830);
auto *central = new QWidget; auto *layout = new QVBoxLayout(central); setCentralWidget(central);
m_message = note(compatibilityError()); layout->addWidget(m_message);
auto *tabs = new QTabWidget; m_tabs = tabs; layout->addWidget(tabs);
auto addPage = [&](QWidget *page, const QString &title) {
auto *scroll = new QScrollArea; scroll->setWidgetResizable(true); scroll->setWidget(page); tabs->addTab(scroll, title);
};
addPage(monitorPage(), "Monitor");
m_cpuPage = new CpuPage;
m_controlPages = {lightingPage(), coolingPage(), batteryPage(), m_cpuPage};
const QStringList titles{"Lighting", "Cooling", "Battery", "CPU"};
for (int i = 0; i < m_controlPages.size(); ++i) {
addPage(m_controlPages[i], titles[i]);
m_controlPages[i]->setEnabled(compatibilityError().isEmpty());
}
addPage(trayPage(), "Tray icon");
addPage(preferencesPage(), "Preferences");
m_savedTray = m_trayPage->draft(); m_savedPreferences = preferenceValues();
m_trayMetric = m_trayPage->metric(); m_trayMode = m_trayPage->mode(); m_trayStyle = m_trayPage->iconStyle();
for (const auto &key : {"keyboard", "power", "chargeLimit", "chargeWatts", "fan"}) m_savedControls[key] = controlValue(key);
m_pendingBar = new QWidget; m_pendingBar->setObjectName("pendingChanges");
auto *bar = new QHBoxLayout(m_pendingBar);
bar->addWidget(new QLabel("Unsaved changes")); bar->addStretch();
m_saveButton = new QPushButton("Save and Apply"); m_saveButton->setObjectName("saveAll");
m_undoButton = new QPushButton("Undo changes"); m_undoButton->setObjectName("undoAll");
bar->addWidget(m_saveButton); bar->addWidget(m_undoButton); layout->addWidget(m_pendingBar);
connect(m_saveButton, &QPushButton::clicked, this, &Window::saveChanges);
connect(m_undoButton, &QPushButton::clicked, this, &Window::undoChanges);
connect(m_cpuPage, &CpuPage::draftChanged, this, &Window::updatePendingBar);
auto dirty = [this](const QString &key) {
if (m_loadingControls) return;
if (controlValue(key) == m_savedControls[key]) m_dirtyControls.remove(key); else m_dirtyControls.insert(key);
updatePendingBar();
};
connect(m_keyboard, &ValueControl::valueChanged, this, [dirty] { dirty("keyboard"); });
connect(m_power, &ValueControl::valueChanged, this, [dirty] { dirty("power"); });
connect(m_powerAuto, &QCheckBox::toggled, this, [dirty] { dirty("power"); });
connect(m_charge, &ValueControl::valueChanged, this, [dirty] { dirty("chargeLimit"); });
connect(m_watts, &ValueControl::valueChanged, this, [dirty] { dirty("chargeWatts"); });
connect(m_fanMode, &QComboBox::currentIndexChanged, this, [dirty] { dirty("fan"); });
for (auto *control : m_curve + m_curveTemperatures + QVector<ValueControl *>{m_duty})
connect(control, &ValueControl::valueChanged, this, [dirty] { dirty("fan"); });
connect(tabs, &QTabWidget::currentChanged, this, [this](int index) {
if (index >= 1 && index <= 4) refreshControls();
});
connect(&m_controlsTimer, &QTimer::timeout, this, [this] {
if (isVisible() && !m_sleeping && m_tabs->currentIndex() >= 1 && m_tabs->currentIndex() <= 3) refreshControls();
});
m_controlsTimer.start(2000);
m_tray = new QSystemTrayIcon(windowIcon(), this);
auto *menu = new QMenu(this);
menu->addAction("Open Framework Laptop Tools", this, [this] { show(); raise(); activateWindow(); });
menu->addSeparator();
menu->addAction("Quit", qApp, &QApplication::quit);
m_tray->setContextMenu(menu);
connect(m_tray, &QSystemTrayIcon::activated, this, [this](auto reason) {
if (reason == QSystemTrayIcon::Trigger || reason == QSystemTrayIcon::DoubleClick) { show(); raise(); activateWindow(); }
});
m_tray->show();
if (!QDBusConnection::systemBus().connect("org.freedesktop.login1", "/org/freedesktop/login1",
"org.freedesktop.login1.Manager", "PrepareForSleep", this, SLOT(sleepChanged(bool))))
m_message->setText("Sleep notifications unavailable; sleep intervals cannot be marked on the battery graph.");
connect(&m_fastTimer, &QTimer::timeout, this, &Window::sample);
connect(&m_batteryTimer, &QTimer::timeout, this, &Window::sampleBattery);
m_fastTimer.start(m_settings.value("sampling/fast", 1000).toInt());
m_batteryTimer.start(m_settings.value("sampling/battery", 30000).toInt());
updatePendingBar(); sample(); sampleBattery(); refreshControls();
}
QGroupBox *Window::sensorGroup(const QString &title, Chart *chart, const QVector<Sensor> &sensors, bool temperatures)
{
auto *group = new QGroupBox; auto *layout = new QVBoxLayout(group);
auto *heading = new QHBoxLayout;
auto *label = new QLabel(title); label->setObjectName("chartTitle");
auto font = label->font(); font.setPointSizeF(font.pointSizeF() + 2); label->setFont(font);
heading->addWidget(label);
if (title == "Fan speed" || title == "Battery") {
auto *details = new QLabel; details->setWordWrap(true); heading->addWidget(details, 1);
if (title == "Fan speed") m_fanDetails = details; else m_batteryDetails = details;
} else heading->addStretch();
layout->addLayout(heading);
QVector<Sensor> main, extra;
for (const auto &sensor : sensors) {
chart->addSeries(sensor.id, sensor.name, sensor.unit);
chart->setSelected(sensor.id, m_settings.value("series/" + sensor.id, !temperatures || sensor.primary).toBool());
(temperatures && !sensor.primary ? extra : main).append(sensor);
}
auto makeLegend = [&](const QVector<Sensor> &entries) {
auto *legend = new Legend(chart, entries);
connect(legend, &Legend::selectionChanged, this, [this](const QString &id, bool selected) {
m_settings.setValue("series/" + id, selected);
});
return legend;
};
auto *mainLegend = makeLegend(main); layout->addWidget(mainLegend);
if (!extra.isEmpty()) {
auto *legend = makeLegend(extra); legend->hide();
auto updateToggle = [mainLegend, legend] {
mainLegend->setExtraLink(QString("%1 More sensors (%2 enabled)")
.arg(legend->isHidden() ? "" : "").arg(legend->enabledCount()));
};
connect(mainLegend, &Legend::extraActivated, this, [legend, updateToggle] {
legend->setVisible(legend->isHidden()); updateToggle();
});
connect(legend, &Legend::selectionChanged, this, updateToggle);
updateToggle(); layout->addWidget(legend);
}
layout->addWidget(chart);
return group;
}
QWidget *Window::monitorPage()
{
auto *page = new QWidget; auto *layout = new QVBoxLayout(page);
m_frequencyChart = new Chart("MHz"); m_temperatureChart = new Chart("°C"); m_batteryChart = new Chart("%");
layout->addWidget(sensorGroup("CPU and GPU frequency", m_frequencyChart, m_frequencies, false));
m_fanChart = new Chart("RPM");
auto *fanGroup = sensorGroup("Fan speed", m_fanChart, {{"fan", "Fan speed", {}, "RPM"}}, false);
layout->addWidget(fanGroup);
layout->addWidget(sensorGroup("Temperatures", m_temperatureChart, m_temperatures, true));
auto *batteryGroup = sensorGroup("Battery", m_batteryChart,
{{"battery", "Charge level", {}, "%"}, {"battery-rate", "Charge / discharge rate", {}, "W"}}, false);
layout->addWidget(batteryGroup);
const QList<Chart *> charts{m_frequencyChart, m_fanChart, m_temperatureChart, m_batteryChart};
for (auto *source : charts) connect(source, &Chart::hovered, this, [charts](qint64 time) {
for (auto *chart : charts) chart->setHoverTime(time);
});
return page;
}
QWidget *Window::lightingPage()
{
auto *page = new QWidget; auto *layout = new QVBoxLayout(page);
auto *keyboard = new QGroupBox("Keyboard"); auto *keys = new QFormLayout(keyboard);
m_keyboard = spin(0, 100, "%"); m_keyboard->setObjectName("keyboardBrightness");
keys->addRow("Keyboard brightness:", m_keyboard);
keys->addRow(note("The current firmware interface does not expose setting or detecting automatic brightness. If Auto is set using Fn+Space, any manual brightness here will be overridden."));
layout->addWidget(keyboard);
auto *power = new QGroupBox("Power button"); auto *lights = new QFormLayout(power);
m_power = spin(1, 100, "%"); m_power->setObjectName("powerBrightness");
m_powerAuto = new QCheckBox("Automatic brightness"); m_powerAuto->setObjectName("powerAuto");
lights->addRow(m_powerAuto); lights->addRow("Power-button brightness:", m_power);
connect(m_powerAuto, &QCheckBox::toggled, m_power, &QWidget::setDisabled);
m_firmwareReadout = note(""); lights->addRow(m_firmwareReadout);
layout->addWidget(power); layout->addStretch(); return page;
}
QWidget *Window::coolingPage()
{
auto *page = new QWidget; auto *layout = new QVBoxLayout(page);
auto *fans = new QGroupBox; auto *form = new QFormLayout(fans);
m_fanReadout = note(""); form->addRow(m_fanReadout);
m_fanMode = new QComboBox; m_fanMode->setObjectName("fanMode");
m_fanMode->addItems({"Firmware Auto", "Manual", "Curve"}); form->addRow("Fan mode:", m_fanMode);
m_duty = spin(0, 100, "%"); m_duty->setObjectName("fanDuty"); m_duty->setValue(50);
form->addRow("Manual speed:", m_duty);
m_fanWarning = note("Warning: below 30% the fan may stop or provide insufficient cooling.");
m_fanWarning->setObjectName("lowFanWarning"); form->addRow(m_fanWarning);
const QList<int> defaults{30, 40, 70, 100};
for (int i = 0; i < 4; ++i) {
auto *point = spin(0, 100, "%"); point->setValue(defaults[i]); m_curve.append(point);
auto *temperature = spin(20, 85, " °C"); temperature->setValue(40 + 15 * i); m_curveTemperatures.append(temperature);
auto *row = new QWidget; auto *line = new QHBoxLayout(row); line->setContentsMargins(0, 0, 0, 0);
line->addWidget(temperature); line->addWidget(point); form->addRow(QString("Curve point %1:").arg(i + 1), row);
}
auto modeChanged = [this] {
const int index = m_fanMode->currentIndex();
m_duty->setEnabled(index == 1);
m_fanWarning->setVisible(index == 1 && m_duty->value() < 30);
for (int i = 0; i < m_curve.size(); ++i) m_curve[i]->setEnabled(index == 2 && i != 3);
for (auto *point : m_curveTemperatures) point->setEnabled(index == 2);
};
connect(m_fanMode, &QComboBox::currentIndexChanged, this, modeChanged);
connect(m_duty, &ValueControl::valueChanged, this, modeChanged); modeChanged();
form->addRow(note("Uses the hottest EC sensor. The final curve point must reach 100% by 85 °C."));
fans->setEnabled(!ecHwmon().isEmpty()); layout->addWidget(fans); layout->addStretch(); return page;
}
QWidget *Window::batteryPage()
{
auto *page = new QWidget; auto *layout = new QVBoxLayout(page);
auto *group = new QGroupBox("Battery"); auto *form = new QFormLayout(group);
m_charge = spin(50, 100, "%"); m_charge->setValue(100); m_charge->setObjectName("chargeLimit");
form->addRow("Limit battery:", m_charge);
m_watts = spin(0, 75, " W"); m_watts->setSpecialValueText("Firmware default"); m_watts->setObjectName("chargeWatts");
m_watts->setToolTip("Zero restores firmware defaults. The current charging-power limit cannot be read back.");
form->addRow("Limit charging power:", m_watts);
form->addRow(note("Limiting charging power is approximate."));
layout->addWidget(group); layout->addStretch(); return page;
}
QWidget *Window::preferencesPage()
{
auto *page = new QWidget; auto *layout = new QFormLayout(page);
m_fastChoice = new QComboBox; m_fastChoice->setObjectName("fastInterval");
m_batteryChoice = new QComboBox; m_batteryChoice->setObjectName("batteryInterval");
for (int ms : {500, 1000, 2000, 4000}) m_fastChoice->addItem(ms == 1000 ? "1 second" : QString("%1 seconds").arg(ms / 1000.), ms);
for (int ms : {15000, 30000, 60000, 120000}) m_batteryChoice->addItem(ms < 60000 ? QString("%1 seconds").arg(ms / 1000) : ms == 60000 ? "1 minute" : "2 minutes", ms);
m_fastChoice->setCurrentIndex(std::max(0, m_fastChoice->findData(m_settings.value("sampling/fast", 1000))));
m_batteryChoice->setCurrentIndex(std::max(0, m_batteryChoice->findData(m_settings.value("sampling/battery", 30000))));
layout->addRow("Frequency / temperature updates:", m_fastChoice); layout->addRow("Battery graph updates:", m_batteryChoice);
m_autostart = new QCheckBox("Start in the tray when I log in"); m_autostart->setObjectName("loginAutostart");
m_autostartPath = QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation) + "/autostart/se.ajpanton.framework-laptop-tools.desktop";
m_autostart->setChecked(QFile::exists(m_autostartPath)); layout->addRow(m_autostart);
connect(m_fastChoice, &QComboBox::currentIndexChanged, this, &Window::updatePendingBar);
connect(m_batteryChoice, &QComboBox::currentIndexChanged, this, &Window::updatePendingBar);
connect(m_autostart, &QCheckBox::toggled, this, &Window::updatePendingBar);
return page;
}
QWidget *Window::trayPage()
{
QVector<Sensor> sensors{{"cpu-usage", "CPU usage", {}, "%"}};
sensors += m_frequencies; sensors += m_temperatures;
sensors += QVector<Sensor>{{"battery", "Battery charge", {}, "%"}, {"battery-rate", "Battery rate", {}, "W"}};
m_trayPage = new TrayPage(m_settings, sensors);
connect(m_trayPage, &TrayPage::settingsChanged, this, &Window::updatePendingBar);
return m_trayPage;
}
void Window::updateTray()
{
const auto metric = m_trayMetric;
QVector<QPointF> history;
if (metric.id == "cpu-usage") history = m_usageHistory;
else if (metric.id == "battery" || metric.id == "battery-rate") history = m_batteryChart->history(metric.id);
else history = (metric.unit == "MHz" ? m_frequencyChart : m_temperatureChart)->history(metric.id);
if (m_trayMode == "icon") m_tray->setIcon(windowIcon());
else m_tray->setIcon(telemetryIcon(m_trayMode == "graph", history, metric.unit, m_trayStyle));
const auto value = m_values.constFind(metric.id);
m_tray->setToolTip(metric.name + ": " + (value == m_values.cend() ? "" : QString::number(*value, 'f', 1) + " " + metric.unit)
+ "\n" + m_batteryText + "\n" + m_fanReadout->text());
}
QVariantMap Window::controlValue(const QString &key) const
{
if (key == "power") return m_powerAuto->isChecked() ? QVariantMap{{"operation", "powerAuto"}, {"value", m_power->value()}}
: QVariantMap{{"operation", "powerBrightness"}, {"value", m_power->value()}};
if (key == "fan") {
QVariantList curve, temperatures;
for (auto *point : m_curve) curve.append(point->value());
for (auto *point : m_curveTemperatures) temperatures.append(point->value());
return {{"operation", "fan"}, {"mode", QStringList{"auto", "manual", "curve"}[m_fanMode->currentIndex()]},
{"duty", m_duty->value()}, {"curve", curve}, {"temperatures", temperatures}};
}
auto *control = key == "keyboard" ? m_keyboard : key == "chargeLimit" ? m_charge : m_watts;
return {{"operation", key}, {"value", control->value()}};
}
void Window::loadControl(const QString &key, const QVariantMap &value)
{
if (key == "power") {
m_powerAuto->setChecked(value["operation"] == "powerAuto");
if (value.contains("value")) m_power->setValue(value["value"].toInt());
} else if (key == "fan") {
m_fanMode->setCurrentIndex(QStringList{"auto", "manual", "curve"}.indexOf(value["mode"].toString()));
m_duty->setValue(value["duty"].toInt());
for (int i = 0; i < m_curve.size(); ++i) {
m_curve[i]->setValue(value["curve"].toList()[i].toInt());
m_curveTemperatures[i]->setValue(value["temperatures"].toList()[i].toInt());
}
} else {
auto *control = key == "keyboard" ? m_keyboard : key == "chargeLimit" ? m_charge : m_watts;
control->setValue(value["value"].toInt());
}
}
QVariantMap Window::preferenceValues() const
{
return {{"sampling/fast", m_fastChoice->currentData()}, {"sampling/battery", m_batteryChoice->currentData()},
{"autostart", m_autostart->isChecked()}};
}
void Window::updatePendingBar()
{
if (!m_pendingBar || m_loadingControls) return;
const bool dirty = !m_dirtyControls.isEmpty() || m_cpuPage->dirty() ||
m_trayPage->draft() != m_savedTray || preferenceValues() != m_savedPreferences;
m_pendingBar->setVisible(dirty || m_saving);
m_saveButton->setEnabled(!m_busy && !m_saving); m_undoButton->setEnabled(!m_busy && !m_saving);
m_tabs->setEnabled(!m_saving);
}
void Window::undoChanges()
{
if (m_busy || m_saving) return;
m_loadingControls = true;
for (auto it = m_savedControls.cbegin(); it != m_savedControls.cend(); ++it) loadControl(it.key(), it.value());
m_dirtyControls.clear(); m_cpuPage->undo(); m_trayPage->load(m_savedTray);
m_fastChoice->setCurrentIndex(m_fastChoice->findData(m_savedPreferences["sampling/fast"]));
m_batteryChoice->setCurrentIndex(m_batteryChoice->findData(m_savedPreferences["sampling/battery"]));
m_autostart->setChecked(m_savedPreferences["autostart"].toBool());
m_loadingControls = false; m_message->clear(); updatePendingBar(); refreshControls();
}
QStringList Window::dangerousChanges() const
{
QStringList warnings;
if (m_dirtyControls.contains("fan")) {
if (m_fanMode->currentIndex() == 1 && m_duty->value() < 30)
warnings << QString("Manual fan speed: %1%. The fan may stop or provide insufficient cooling.").arg(m_duty->value());
if (m_fanMode->currentIndex() == 2) {
for (auto *point : m_curve) if (point->value() < 30) {
warnings << "Fan curve includes speeds below 30%. The fan may stop at those temperatures."; break;
}
}
}
return warnings;
}
void Window::saveChanges()
{
if (m_busy || m_saving) return;
QString error;
if (m_dirtyControls.contains("fan")) error = validateFan(controlValue("fan"));
if (error.isEmpty() && m_cpuPage->dirty()) error = validateCpuConfig(m_cpuPage->draft());
if (!error.isEmpty()) { m_message->setText(error); return; }
const auto warnings = dangerousChanges();
if (!warnings.isEmpty()) {
QMessageBox confirmation(QMessageBox::Warning, "Apply potentially dangerous settings?",
warnings.join("\n\n") + "\n\nThese settings will also be restored after reboot and sleep. Apply them?",
QMessageBox::Yes | QMessageBox::Cancel, this);
confirmation.setDefaultButton(QMessageBox::Cancel);
if (confirmation.exec() != QMessageBox::Yes) return;
}
QVariantList operations;
for (const auto &key : {"keyboard", "power", "chargeLimit", "chargeWatts", "fan"})
if (m_dirtyControls.contains(key)) operations.append(controlValue(key));
if (m_cpuPage->dirty()) operations.append(QVariantMap{{"operation", "cpuProfiles"}, {"config", m_cpuPage->draft()}});
m_saving = true; updatePendingBar();
if (operations.isEmpty()) finishSave(); else request({{"operation", "batch"}, {"operations", operations}});
}
QString Window::saveLocalSettings()
{
const auto preferences = preferenceValues();
if (preferences["autostart"] != m_savedPreferences["autostart"]) {
if (m_autostart->isChecked()) {
if (!QDir().mkpath(QFileInfo(m_autostartPath).absolutePath())) return "Could not create the autostart directory.";
QSaveFile file(m_autostartPath);
const QByteArray data("[Desktop Entry]\nType=Application\nName=Framework Laptop Tools\nExec=framework-laptop-tools --tray\nIcon=computer-laptop\n");
if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size() || !file.commit())
return "Could not save login autostart: " + file.errorString();
} else if (QFile::exists(m_autostartPath) && !QFile::remove(m_autostartPath)) return "Could not remove login autostart.";
}
const auto tray = m_trayPage->draft();
for (const auto &key : m_settings.allKeys()) if (key.startsWith("tray/")) m_settings.remove(key);
for (auto it = tray.cbegin(); it != tray.cend(); ++it) m_settings.setValue(it.key(), it.value());
for (const auto &key : {"sampling/fast", "sampling/battery"}) m_settings.setValue(key, preferences[key]);
m_settings.sync();
if (m_settings.status() != QSettings::NoError) return "Could not save application settings.";
m_savedTray = tray; m_savedPreferences = preferences;
m_fastTimer.start(preferences["sampling/fast"].toInt()); m_batteryTimer.start(preferences["sampling/battery"].toInt());
m_trayMetric = m_trayPage->metric(); m_trayMode = m_trayPage->mode(); m_trayStyle = m_trayPage->iconStyle(); updateTray();
return {};
}
void Window::finishSave()
{
const auto error = saveLocalSettings();
m_saving = false;
m_message->setText(error.isEmpty() ? "Settings saved and applied." : "Save stopped: " + error + " Earlier changes may already have applied.");
updatePendingBar(); refreshControls();
}
void Window::refreshControls()
{
if (m_busy || m_saving) return;
m_loadingControls = true;
const auto keyboard = readNumber("/sys/class/leds/chromeos::kbd_backlight/brightness");
m_keyboard->setEnabled(keyboard.has_value());
if (keyboard && !m_dirtyControls.contains("keyboard")) {
m_keyboard->setValue(int(*keyboard)); m_savedControls["keyboard"] = controlValue("keyboard");
}
m_loadingControls = false;
if (compatibilityError().isEmpty()) request({}, true);
}
void Window::request(const QVariantMap &arguments, bool inspect)
{
if (m_busy) return;
if (!compatibilityError().isEmpty()) {
m_saving = false; m_message->setText(compatibilityError()); updatePendingBar(); return;
}
m_busy = true; updatePendingBar();
if (!inspect) m_message->setText("Applying settings…");
KAuth::Action action(QString("se.ajpanton.frameworktools.") + (inspect ? "inspect" : "configure"));
action.setHelperId("se.ajpanton.frameworktools"); action.setArguments(arguments); action.setTimeout(inspect ? 60000 : 180000);
auto *job = action.execute();
connect(job, &KJob::result, this, [this, job, inspect](KJob *) {
m_busy = false;
if (job->error()) {
m_message->setText(inspect ? job->errorString() : "Save stopped: " + job->errorString() + " Earlier changes may already have applied; remaining edits are still unsaved.");
m_saving = false; updatePendingBar(); return;
}
if (inspect) {
m_loadingControls = true;
const auto data = job->data();
if (!data.contains("cpuError")) m_cpuPage->load(data["cpuConfig"].toMap());
if (data.contains("chargeLimit")) {
m_chargeLimit = data["chargeLimit"].toInt();
if (!m_dirtyControls.contains("chargeLimit")) m_charge->setValue(m_chargeLimit);
}
m_chargeOverride = data.value("chargeOverride").toBool();
if (data.contains("fanMode") && !m_dirtyControls.contains("fan")) {
m_fanMode->setCurrentIndex(QStringList{"auto", "manual", "curve"}.indexOf(data["fanMode"].toString()));
if (data.contains("fanDuty")) m_duty->setValue(data["fanDuty"].toInt());
const auto curve = data["fanCurve"].toList(), temperatures = data["fanTemperatures"].toList();
if (curve.size() == m_curve.size()) for (int i = 0; i < curve.size(); ++i) m_curve[i]->setValue(curve[i].toInt());
if (temperatures.size() == m_curveTemperatures.size()) for (int i = 0; i < temperatures.size(); ++i) m_curveTemperatures[i]->setValue(temperatures[i].toInt());
}
if (!m_dirtyControls.contains("power")) {
if (data.contains("powerBrightness")) m_power->setValue(data["powerBrightness"].toInt());
if (data.contains("powerAuto")) m_powerAuto->setChecked(data["powerAuto"].toBool());
}
for (const auto &key : {"power", "chargeLimit", "fan"}) if (!m_dirtyControls.contains(key)) m_savedControls[key] = controlValue(key);
m_firmwareReadout->setText(data.contains("powerBrightness") ? QString("Current: %1% (%2)").arg(data["powerBrightness"].toInt()).arg(data["powerAuto"].toBool() ? "Auto" : "fixed") : data["powerError"].toString());
for (const auto &key : {"cpuError", "chargeError", "fanError"}) if (data.contains(key)) m_message->setText(data[key].toString());
m_loadingControls = false; updatePendingBar();
} else {
const auto data = job->data();
for (const auto &entry : data.value("completed").toList()) {
const auto value = entry.toMap(); const auto operation = value["operation"].toString();
if (operation == "cpuProfiles") m_cpuPage->load(value["config"].toMap(), true);
else {
const auto key = operation.startsWith("power") ? QString("power") : operation;
m_savedControls[key] = value; m_dirtyControls.remove(key);
}
}
if (data.contains("applyError")) {
m_saving = false;
m_message->setText("Save stopped: " + data["applyError"].toString() + " Earlier changes may already have applied; remaining edits are still unsaved.");
updatePendingBar(); refreshControls();
} else finishSave();
}
});
job->start();
}
void Window::sample()
{
if (m_sleeping) return;
m_values.clear();
m_cpuPage->refreshStatus();
auto sampleSensors = [&](const QVector<Sensor> &sensors, Chart *chart) {
QMap<QString, double> values;
for (const auto &sensor : sensors) {
const auto n = sensorValue(sensor);
if (n) { values.insert(sensor.id, *n); m_values.insert(sensor.id, *n); }
}
chart->sample(values, m_fastTimer.interval());
};
sampleSensors(m_frequencies, m_frequencyChart); sampleSensors(m_temperatures, m_temperatureChart);
const QString ec = ecHwmon();
const auto rpm = readNumber(ec + "/fan1_input");
const auto mode = readNumber(ec + "/pwm1_enable");
const auto pwm = readNumber(ec + "/pwm1");
const QString fanMode = !mode ? "Mode unavailable" : *mode == 2 ? "Firmware Auto" : "Manual / curve override";
m_fanDetails->setText("· " + fanMode);
m_fanReadout->setText((rpm ? QString("%1 RPM").arg(*rpm, 0, 'f', 0) : "Fan speed unavailable")
+ (pwm ? QString(" · Duty: %1%").arg(*pwm * 100 / 255, 0, 'f', 0) : " · Duty unavailable") + " · " + fanMode);
QMap<QString, double> fans;
if (rpm) fans["fan"] = *rpm;
m_fanChart->sample(fans, m_fastTimer.interval());
const auto battery = batteryStatus();
const auto usage = m_cpuUsage.sample(readText("/proc/stat"));
if (usage) m_values["cpu-usage"] = *usage;
m_usageHistory.append({double(QDateTime::currentMSecsSinceEpoch()), usage.value_or(NAN)});
if (m_usageHistory.size() > 600) m_usageHistory.removeFirst();
if (battery.contains("capacity")) m_values["battery"] = battery["capacity"].toDouble();
const auto rate = batteryRate(battery); if (rate) m_values["battery-rate"] = *rate;
m_batteryChart->setPowerState(false, onAcPower());
QString text = battery.isEmpty() ? "Battery unavailable" : QString("Battery %1% · %2").arg(battery.value("capacity").toInt()).arg(battery.value("state").toString());
if (battery.contains("watts")) text += QString(" · %1 W").arg(battery["watts"].toDouble(), 0, 'f', 1);
if (battery.contains("health")) text += QString(" · Capacity / design %1%").arg(battery["health"].toDouble(), 0, 'f', 0);
if (battery.contains("cycle_count")) text += QString(" · %1 cycles").arg(battery["cycle_count"].toInt());
QStringList details;
if (battery.contains("fullMWh")) details << QString("Capacity: %1 mWh").arg(battery["fullMWh"].toDouble(), 0, 'f', 0);
if (battery.contains("health")) details << QString("Health: %1%").arg(battery["health"].toDouble(), 0, 'f', 0);
if (battery.contains("cycle_count")) details << QString("%1 cycles").arg(battery["cycle_count"].toInt());
m_batteryDetails->setText("· " + details.join(" · "));
const int effectiveLimit = m_chargeOverride ? 100 : m_chargeLimit;
QDBusInterface properties("org.freedesktop.UPower", "/org/freedesktop/UPower/devices/DisplayDevice", "org.freedesktop.DBus.Properties", QDBusConnection::systemBus());
properties.setTimeout(250);
const QDBusReply<QVariantMap> response = properties.call("GetAll", "org.freedesktop.UPower.Device");
if (response.isValid()) {
if (battery["state"] == "Discharging" && response.value()["TimeToEmpty"].toLongLong() > 0)
text += " · " + duration(response.value()["TimeToEmpty"].toDouble()) + " remaining (OS estimate)";
else if (battery["state"] == "Charging" && response.value()["TimeToFull"].toLongLong() > 0 && effectiveLimit == 100)
text += " · " + duration(response.value()["TimeToFull"].toDouble()) + " until full (OS estimate)";
}
if (battery["state"] == "Charging" && effectiveLimit < 100 && battery["current_now"].toDouble() > 0
&& battery.contains("charge_now") && battery["charge_full"].toDouble() > 0) {
const double remaining = battery["charge_full"].toDouble() * effectiveLimit / 100 - battery["charge_now"].toDouble();
text += remaining > 0 ? " · about " + duration(remaining / battery["current_now"].toDouble() * 3600) + QString(" to %1% (at current rate)").arg(effectiveLimit) : " · charge limit reached";
}
m_batteryText = text;
updateTray();
}
void Window::sampleBattery()
{
if (m_sleeping) return;
const auto battery = batteryStatus(); QMap<QString, double> values;
if (battery.contains("capacity")) values["battery"] = battery["capacity"].toDouble();
const auto rate = batteryRate(battery); if (rate) values["battery-rate"] = *rate;
m_batteryChart->sample(values, m_batteryTimer.interval());
updateTray();
}
void Window::sleepChanged(bool sleeping)
{
m_sleeping = sleeping;
for (auto *chart : {m_frequencyChart, m_temperatureChart, m_batteryChart, m_fanChart})
chart->setPowerState(sleeping, sleeping || chart != m_batteryChart ? std::nullopt : onAcPower());
m_cpuUsage.reset();
if (!sleeping) { sample(); sampleBattery(); }
}
void Window::closeEvent(QCloseEvent *event)
{
if (QSystemTrayIcon::isSystemTrayAvailable()) { hide(); event->ignore(); }
else event->accept();
}
+86
View File
@@ -0,0 +1,86 @@
// SPDX-License-Identifier: MIT
#pragma once
#include "chart.h"
#include "cpupage.h"
#include "tray.h"
#include "traypage.h"
#include <QMainWindow>
#include <QSettings>
#include <QTimer>
#include <QLabel>
#include <QSystemTrayIcon>
#include <QCheckBox>
#include <QComboBox>
class QGroupBox;
class QTabWidget;
class QPushButton;
class Window : public QMainWindow {
Q_OBJECT
public:
Window();
protected:
void closeEvent(QCloseEvent *event) override;
private:
QWidget *monitorPage();
QWidget *lightingPage();
QWidget *coolingPage();
QWidget *batteryPage();
QWidget *preferencesPage();
QWidget *trayPage();
void updateTray();
void sample();
void sampleBattery();
void request(const QVariantMap &arguments, bool inspect = false);
void refreshControls();
QVariantMap controlValue(const QString &key) const;
void loadControl(const QString &key, const QVariantMap &value);
QVariantMap preferenceValues() const;
void updatePendingBar();
void saveChanges();
void undoChanges();
void finishSave();
QString saveLocalSettings();
QStringList dangerousChanges() const;
QGroupBox *sensorGroup(const QString &title, Chart *chart, const QVector<Sensor> &sensors, bool temperatures);
QSettings m_settings;
QTimer m_fastTimer, m_batteryTimer, m_controlsTimer;
QTabWidget *m_tabs;
QVector<Sensor> m_temperatures, m_frequencies;
QMap<QString, double> m_values;
CpuUsage m_cpuUsage;
QVector<QPointF> m_usageHistory;
Chart *m_frequencyChart, *m_temperatureChart, *m_batteryChart, *m_fanChart;
QLabel *m_batteryDetails, *m_fanDetails;
QLabel *m_message, *m_fanReadout, *m_firmwareReadout;
QString m_batteryText;
QVector<QWidget *> m_controlPages;
CpuPage *m_cpuPage;
QSystemTrayIcon *m_tray;
ValueControl *m_keyboard, *m_power, *m_charge, *m_watts, *m_duty;
QCheckBox *m_powerAuto;
QLabel *m_fanWarning;
QVector<ValueControl *> m_curve;
QVector<ValueControl *> m_curveTemperatures;
QSet<QString> m_dirtyControls;
bool m_loadingControls = false;
QMap<QString, QVariantMap> m_savedControls;
QVariantMap m_savedTray, m_savedPreferences;
QComboBox *m_fastChoice, *m_batteryChoice;
QCheckBox *m_autostart;
QString m_autostartPath;
QWidget *m_pendingBar = nullptr;
QPushButton *m_saveButton, *m_undoButton;
bool m_saving = false;
Sensor m_trayMetric;
QString m_trayMode;
TrayStyle m_trayStyle;
QComboBox *m_fanMode;
TrayPage *m_trayPage;
int m_chargeLimit = 100;
bool m_chargeOverride = false;
bool m_busy = false;
bool m_sleeping = false;
private Q_SLOTS:
void sleepChanged(bool sleeping);
};