feat: split SystemUsage into separate C++ services

This commit is contained in:
2 * r + 2 * t 2026-04-26 01:38:22 +10:00
parent 9fefb53440
commit 81fc9dda02
25 changed files with 1510 additions and 378 deletions

View file

@ -4,6 +4,7 @@ import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import Quickshell.Services.UPower import Quickshell.Services.UPower
import Caelestia.Config import Caelestia.Config
import Caelestia.Services
import qs.components import qs.components
import qs.components.controls import qs.components.controls
import qs.services import qs.services
@ -12,8 +13,8 @@ SectionContainer {
id: root id: root
required property var rootItem required property var rootItem
// GPU toggle is hidden when gpuType is "NONE" (no GPU data available) // GPU toggle is hidden when type is Gpu.None (no GPU data available)
readonly property bool gpuAvailable: SystemUsage.gpuType !== "NONE" readonly property bool gpuAvailable: Gpu.type !== Gpu.None
// Battery toggle is hidden when no laptop battery is present // Battery toggle is hidden when no laptop battery is present
readonly property bool batteryAvailable: UPower.displayDevice.isLaptopBattery readonly property bool batteryAvailable: UPower.displayDevice.isLaptopBattery

View file

@ -5,8 +5,8 @@ import QtQuick.Layouts
import Quickshell.Services.UPower import Quickshell.Services.UPower
import Caelestia.Config import Caelestia.Config
import Caelestia.Internal import Caelestia.Internal
import Caelestia.Services
import qs.components import qs.components
import qs.components.misc
import qs.services import qs.services
Item { Item {
@ -29,7 +29,7 @@ Item {
height: 350 height: 350
radius: Tokens.rounding.extraLarge radius: Tokens.rounding.extraLarge
color: Colours.tPalette.m3surfaceContainer color: Colours.tPalette.m3surfaceContainer
visible: !Config.dashboard.performance.showCpu && !(Config.dashboard.performance.showGpu && SystemUsage.gpuType !== "NONE") && !Config.dashboard.performance.showMemory && !Config.dashboard.performance.showStorage && !Config.dashboard.performance.showNetwork && !(UPower.displayDevice.isLaptopBattery && Config.dashboard.performance.showBattery) visible: !Config.dashboard.performance.showCpu && !(Config.dashboard.performance.showGpu && Gpu.type !== Gpu.None) && !Config.dashboard.performance.showMemory && !Config.dashboard.performance.showStorage && !Config.dashboard.performance.showNetwork && !(UPower.displayDevice.isLaptopBattery && Config.dashboard.performance.showBattery)
ColumnLayout { ColumnLayout {
anchors.centerIn: parent anchors.centerIn: parent
@ -66,8 +66,12 @@ Item {
spacing: Tokens.spacing.medium spacing: Tokens.spacing.medium
visible: !placeholder.visible visible: !placeholder.visible
Ref { ServiceRef {
service: SystemUsage service: root.Config.dashboard.performance.showCpu ? Cpu : null
}
ServiceRef {
service: root.Config.dashboard.performance.showGpu && Gpu.type !== Gpu.None ? Gpu : null
} }
ColumnLayout { ColumnLayout {
@ -79,27 +83,27 @@ Item {
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
spacing: Tokens.spacing.medium spacing: Tokens.spacing.medium
visible: Config.dashboard.performance.showCpu || (Config.dashboard.performance.showGpu && SystemUsage.gpuType !== "NONE") visible: Config.dashboard.performance.showCpu || (Config.dashboard.performance.showGpu && Gpu.type !== Gpu.None)
HeroCard { HeroCard {
Layout.fillWidth: true Layout.fillWidth: true
visible: Config.dashboard.performance.showCpu visible: Config.dashboard.performance.showCpu
icon: "memory" icon: "memory"
label: qsTr("CPU") label: qsTr("CPU")
subLabel: SystemUsage.cpuName subLabel: Cpu.name
usage: SystemUsage.cpuPerc usage: Cpu.percentage
temperature: SystemUsage.cpuTemp temperature: Cpu.temperature
accent: Colours.palette.m3primary accent: Colours.palette.m3primary
} }
HeroCard { HeroCard {
Layout.fillWidth: true Layout.fillWidth: true
visible: Config.dashboard.performance.showGpu && SystemUsage.gpuType !== "NONE" visible: Config.dashboard.performance.showGpu && Gpu.type !== Gpu.None
icon: "desktop_windows" icon: "desktop_windows"
label: qsTr("GPU") label: qsTr("GPU")
subLabel: SystemUsage.gpuName subLabel: Gpu.name
usage: SystemUsage.gpuPerc usage: Gpu.percentage
temperature: SystemUsage.gpuTemp temperature: Gpu.temperature
accent: Colours.palette.m3secondary accent: Colours.palette.m3secondary
} }
} }

View file

@ -1,9 +1,9 @@
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import Caelestia.Config import Caelestia.Config
import Caelestia.Services
import qs.components import qs.components
import qs.components.controls import qs.components.controls
import qs.components.misc
import qs.services import qs.services
Item { Item {
@ -14,8 +14,16 @@ Item {
implicitWidth: layout.implicitWidth + layout.anchors.margins * 2 implicitWidth: layout.implicitWidth + layout.anchors.margins * 2
Ref { ServiceRef {
service: SystemUsage service: Cpu
}
ServiceRef {
service: Memory
}
ServiceRef {
service: Storage
} }
ColumnLayout { ColumnLayout {
@ -29,18 +37,18 @@ Item {
Resource { Resource {
icon: "memory" icon: "memory"
value: SystemUsage.cpuPerc value: Cpu.percentage
} }
Resource { Resource {
icon: "memory_alt" icon: "memory_alt"
value: SystemUsage.memPerc value: Memory.percentage
fgColour: Colours.palette.m3tertiary fgColour: Colours.palette.m3tertiary
} }
Resource { Resource {
icon: "hard_disk" icon: "hard_disk"
value: SystemUsage.storagePerc value: Storage.percentage
fgColour: Colours.palette.m3secondary fgColour: Colours.palette.m3secondary
} }
} }

View file

@ -1,6 +1,7 @@
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import Caelestia.Config import Caelestia.Config
import Caelestia.Services
import qs.components import qs.components
import qs.components.controls import qs.components.controls
import qs.services import qs.services
@ -16,6 +17,10 @@ StyledRect {
implicitWidth: layout.implicitWidth + Tokens.padding.extraLargeIncreased * 2 implicitWidth: layout.implicitWidth + Tokens.padding.extraLargeIncreased * 2
implicitHeight: layout.implicitHeight + Tokens.padding.large * 2 implicitHeight: layout.implicitHeight + Tokens.padding.large * 2
ServiceRef {
service: Memory
}
ColumnLayout { ColumnLayout {
id: layout id: layout
@ -47,7 +52,7 @@ StyledRect {
sweepAngle: 270 sweepAngle: 270
fgColour: root.accent fgColour: root.accent
value: SystemUsage.memPerc value: Memory.percentage
Behavior on clampedVal { Behavior on clampedVal {
Anim {} Anim {}
@ -62,7 +67,7 @@ StyledRect {
StyledText { StyledText {
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
text: Math.round(SystemUsage.memPerc * 100) + "%" text: Math.round(Memory.percentage * 100) + "%"
font: Tokens.font.title.builders.large.width(90).build() font: Tokens.font.title.builders.large.width(90).build()
color: root.accent color: root.accent
} }
@ -79,8 +84,8 @@ StyledRect {
StyledText { StyledText {
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
text: { text: {
const usedFmt = SystemUsage.formatKib(SystemUsage.memUsed); const usedFmt = Memory.formatKib(Memory.used);
const totalFmt = SystemUsage.formatKib(SystemUsage.memTotal); const totalFmt = Memory.formatKib(Memory.total);
return `${usedFmt.value.toFixed(1)} / ${Math.floor(totalFmt.value)} ${totalFmt.unit}`; return `${usedFmt.value.toFixed(1)} / ${Math.floor(totalFmt.value)} ${totalFmt.unit}`;
} }
font: Tokens.font.body.medium font: Tokens.font.body.medium

View file

@ -2,6 +2,7 @@ import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import Quickshell import Quickshell
import Caelestia.Config import Caelestia.Config
import Caelestia.Services
import qs.components import qs.components
import qs.components.controls import qs.components.controls
import qs.services import qs.services
@ -10,7 +11,7 @@ StyledRect {
id: root id: root
readonly property color accent: Colours.palette.m3secondary readonly property color accent: Colours.palette.m3secondary
readonly property real percentage: SystemUsage.primaryDisk?.perc ?? 0 readonly property real percentage: Storage.primaryDisk?.perc ?? 0
color: Colours.tPalette.m3surfaceContainer color: Colours.tPalette.m3surfaceContainer
radius: Tokens.rounding.extraExtraLarge radius: Tokens.rounding.extraExtraLarge
@ -18,6 +19,10 @@ StyledRect {
implicitWidth: layout.implicitWidth + layout.anchors.margins * 2 implicitWidth: layout.implicitWidth + layout.anchors.margins * 2
implicitHeight: layout.implicitHeight + Tokens.padding.large * 2 implicitHeight: layout.implicitHeight + Tokens.padding.large * 2
ServiceRef {
service: Storage
}
ColumnLayout { ColumnLayout {
id: layout id: layout
@ -83,11 +88,11 @@ StyledRect {
StyledText { StyledText {
text: { text: {
if (!SystemUsage.primaryDisk) if (!Storage.primaryDisk)
return qsTr("No disks detected"); return qsTr("No disks detected");
const usedFmt = SystemUsage.formatKib(SystemUsage.primaryDisk.used); const usedFmt = Storage.formatKib(Storage.primaryDisk.used);
const totalFmt = SystemUsage.formatKib(SystemUsage.primaryDisk.total); const totalFmt = Storage.formatKib(Storage.primaryDisk.total);
return `${usedFmt.value.toFixed(1)} / ${Math.floor(totalFmt.value)} ${totalFmt.unit}`; return `${usedFmt.value.toFixed(1)} / ${Math.floor(totalFmt.value)} ${totalFmt.unit}`;
} }
font: Tokens.font.body.large font: Tokens.font.body.large
@ -100,20 +105,20 @@ StyledRect {
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
type: SplitButton.Tonal type: SplitButton.Tonal
disabled: !SystemUsage.disks.length disabled: !Storage.disks.length
fallbackIcon: "storage" fallbackIcon: "storage"
fallbackText: qsTr("No disks") fallbackText: qsTr("No disks")
menuOnTop: true menuOnTop: true
minLeftWidth: row.implicitWidth * 0.6 minLeftWidth: row.implicitWidth * 0.6
menuItems: disks.instances menuItems: disks.instances
active: menuItems.find(m => m.modelData === SystemUsage.primaryDisk) ?? menuItems[0] ?? null active: menuItems.find(m => m.modelData === Storage.primaryDisk) ?? menuItems[0] ?? null
menu.onItemSelected: item => SystemUsage.manualPrimaryDisk = (item as DiskItem).modelData menu.onItemSelected: item => Storage.manualPrimaryDisk = (item as DiskItem).modelData
Variants { Variants {
id: disks id: disks
model: SystemUsage.disks model: Storage.disks
DiskItem {} DiskItem {}
} }
@ -123,7 +128,7 @@ StyledRect {
component DiskItem: MenuItem { component DiskItem: MenuItem {
required property var modelData required property var modelData
icon: modelData === SystemUsage.primaryDisk ? "check" : "" icon: modelData === Storage.primaryDisk ? "check" : ""
text: modelData.mount text: modelData.mount
activeIcon: "storage" activeIcon: "storage"
} }

View file

@ -1,9 +1,9 @@
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import Caelestia.Config import Caelestia.Config
import Caelestia.Services
import qs.components import qs.components
import qs.components.controls import qs.components.controls
import qs.components.misc
import qs.services import qs.services
GridLayout { GridLayout {
@ -18,35 +18,43 @@ GridLayout {
rows: 2 rows: 2
columns: 2 columns: 2
Ref { ServiceRef {
service: SystemUsage service: Cpu
}
ServiceRef {
service: Memory
}
ServiceRef {
service: Storage
} }
Resource { Resource {
Layout.topMargin: Tokens.padding.large Layout.topMargin: Tokens.padding.large
icon: "memory" icon: "memory"
value: SystemUsage.cpuPerc value: Cpu.percentage
colour: Colours.palette.m3primary colour: Colours.palette.m3primary
} }
Resource { Resource {
Layout.topMargin: Tokens.padding.large Layout.topMargin: Tokens.padding.large
icon: "thermostat" icon: "thermostat"
value: Math.min(1, SystemUsage.cpuTemp / 90) value: Math.min(1, Cpu.temperature / 90)
colour: Colours.palette.m3secondary colour: Colours.palette.m3secondary
} }
Resource { Resource {
Layout.bottomMargin: Tokens.padding.large Layout.bottomMargin: Tokens.padding.large
icon: "memory_alt" icon: "memory_alt"
value: SystemUsage.memPerc value: Memory.percentage
colour: Colours.palette.m3secondary colour: Colours.palette.m3secondary
} }
Resource { Resource {
Layout.bottomMargin: Tokens.padding.large Layout.bottomMargin: Tokens.padding.large
icon: "hard_disk" icon: "hard_disk"
value: SystemUsage.storagePerc value: Storage.percentage
colour: Colours.palette.m3tertiary colour: Colours.palette.m3tertiary
} }

View file

@ -8,6 +8,16 @@ if(NOT Cava_FOUND)
pkg_check_modules(Cava IMPORTED_TARGET cava REQUIRED) pkg_check_modules(Cava IMPORTED_TARGET cava REQUIRED)
endif() endif()
find_library(SENSORS_LIBRARY NAMES sensors REQUIRED)
find_path(SENSORS_INCLUDE_DIR NAMES sensors/sensors.h REQUIRED)
if(NOT TARGET Sensors::Sensors)
add_library(Sensors::Sensors UNKNOWN IMPORTED)
set_target_properties(Sensors::Sensors PROPERTIES
IMPORTED_LOCATION "${SENSORS_LIBRARY}"
INTERFACE_INCLUDE_DIRECTORIES "${SENSORS_INCLUDE_DIR}"
)
endif()
set(QT_QML_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qml") set(QT_QML_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qml")
qt_standard_project_setup(REQUIRES 6.9) qt_standard_project_setup(REQUIRES 6.9)

View file

@ -1,14 +1,25 @@
qml_module(caelestia-services qml_module(caelestia-services
URI Caelestia.Services URI Caelestia.Services
SOURCES SOURCES
service.hpp service.cpp service.cpp
serviceref.hpp serviceref.cpp serviceref.cpp
beattracker.hpp beattracker.cpp beattracker.cpp
audiocollector.hpp audiocollector.cpp audiocollector.cpp
audioprovider.hpp audioprovider.cpp audioprovider.cpp
cavaprovider.hpp cavaprovider.cpp cavaprovider.cpp
tickingservice.cpp
sensorslib.cpp
usagefmt.cpp
cpu.cpp
gpu.cpp
memory.cpp
diskinfo.cpp
storage.cpp
LIBRARIES LIBRARIES
PkgConfig::Pipewire PkgConfig::Pipewire
PkgConfig::Aubio PkgConfig::Aubio
PkgConfig::Cava PkgConfig::Cava
Sensors::Sensors
caelestia-config
caelestia-internal
) )

View file

@ -0,0 +1,117 @@
#include "cpu.hpp"
#include "sensorslib.hpp"
#include <cmath>
#include <qfile.h>
#include <qregularexpression.h>
namespace caelestia::services {
Cpu::Cpu(QObject* parent)
: TickingService(parent) {
readNameOnce();
}
QString Cpu::name() const {
return m_name;
}
qreal Cpu::percentage() const {
return m_percentage;
}
qreal Cpu::temperature() const {
return m_temperature;
}
void Cpu::tick() {
if (!m_nameLoaded) {
readNameOnce();
}
refreshPercentage();
refreshTemperature();
}
void Cpu::readNameOnce() {
QFile f(QStringLiteral("/proc/cpuinfo"));
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
return;
}
const QByteArray data = f.readAll();
f.close();
static const QRegularExpression re(QStringLiteral("model name\\s*:\\s*(.+)"));
const auto match = re.match(QString::fromLatin1(data));
if (!match.hasMatch()) {
return;
}
const QString cleaned = cleanName(match.captured(1));
m_nameLoaded = true;
if (cleaned == m_name) {
return;
}
m_name = cleaned;
Q_EMIT nameChanged();
}
void Cpu::refreshPercentage() {
QFile f(QStringLiteral("/proc/stat"));
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
return;
}
const QByteArray data = f.readAll();
f.close();
static const QRegularExpression re(
QStringLiteral("^cpu\\s+(\\d+)\\s+(\\d+)\\s+(\\d+)\\s+(\\d+)\\s+(\\d+)\\s+(\\d+)\\s+(\\d+)"));
const auto match = re.match(QString::fromLatin1(data));
if (!match.hasMatch()) {
return;
}
quint64 total = 0;
quint64 idle = 0;
for (int i = 1; i <= 7; ++i) {
const quint64 v = match.captured(i).toULongLong();
total += v;
if (i == 4 || i == 5) {
idle += v;
}
}
const quint64 totalDiff = total > m_lastTotal ? total - m_lastTotal : 0;
const quint64 idleDiff = idle > m_lastIdle ? idle - m_lastIdle : 0;
const qreal newPerc = totalDiff > 0 ? 1.0 - static_cast<qreal>(idleDiff) / static_cast<qreal>(totalDiff) : 0.0;
m_lastTotal = total;
m_lastIdle = idle;
if (std::abs(newPerc - m_percentage) > 0.0001) {
m_percentage = newPerc;
Q_EMIT percentageChanged();
}
}
void Cpu::refreshTemperature() {
const auto t = sensorslib::cpuPackageTemp();
const qreal newTemp = t.value_or(0.0);
if (std::abs(newTemp - m_temperature) > 0.05) {
m_temperature = newTemp;
Q_EMIT temperatureChanged();
}
}
QString Cpu::cleanName(QString s) {
static const QRegularExpression noise(
QStringLiteral("\\(R\\)|\\(TM\\)|CPU|\\d+(?:th|nd|rd|st) Gen |Core |Processor"),
QRegularExpression::CaseInsensitiveOption);
static const QRegularExpression spaces(QStringLiteral("\\s+"));
s.replace(noise, QString());
s.replace(spaces, QStringLiteral(" "));
return s.trimmed();
}
} // namespace caelestia::services

View file

@ -0,0 +1,48 @@
#pragma once
#include "tickingservice.hpp"
#include <qqmlintegration.h>
namespace caelestia::services {
class Cpu : public TickingService {
Q_OBJECT
QML_ELEMENT
QML_SINGLETON
Q_PROPERTY(QString name READ name NOTIFY nameChanged)
Q_PROPERTY(qreal percentage READ percentage NOTIFY percentageChanged)
Q_PROPERTY(qreal temperature READ temperature NOTIFY temperatureChanged)
public:
explicit Cpu(QObject* parent = nullptr);
[[nodiscard]] QString name() const;
[[nodiscard]] qreal percentage() const;
[[nodiscard]] qreal temperature() const;
signals:
void nameChanged();
void percentageChanged();
void temperatureChanged();
protected:
void tick() override;
private:
void readNameOnce();
void refreshPercentage();
void refreshTemperature();
[[nodiscard]] static QString cleanName(QString s);
QString m_name;
qreal m_percentage = 0.0;
qreal m_temperature = 0.0;
quint64 m_lastIdle = 0;
quint64 m_lastTotal = 0;
bool m_nameLoaded = false;
};
} // namespace caelestia::services

View file

@ -0,0 +1,67 @@
#include "diskinfo.hpp"
namespace caelestia::services {
namespace {
constexpr qreal kKib = 1024.0;
} // namespace
DiskInfo::DiskInfo(QString mount, quint64 usedBytes, quint64 totalBytes, bool hasRoot, QObject* parent)
: QObject(parent)
, m_mount(std::move(mount))
, m_usedBytes(usedBytes)
, m_totalBytes(totalBytes)
, m_hasRoot(hasRoot) {}
QString DiskInfo::mount() const {
return m_mount;
}
qreal DiskInfo::used() const {
return static_cast<qreal>(m_usedBytes) / kKib;
}
qreal DiskInfo::total() const {
return static_cast<qreal>(m_totalBytes) / kKib;
}
qreal DiskInfo::free() const {
const quint64 freeBytes = m_totalBytes > m_usedBytes ? m_totalBytes - m_usedBytes : 0;
return static_cast<qreal>(freeBytes) / kKib;
}
qreal DiskInfo::perc() const {
return m_totalBytes > 0 ? static_cast<qreal>(m_usedBytes) / static_cast<qreal>(m_totalBytes) : 0.0;
}
bool DiskInfo::hasRoot() const {
return m_hasRoot;
}
void DiskInfo::update(quint64 usedBytes, quint64 totalBytes, bool hasRoot) {
const bool usedDiff = usedBytes != m_usedBytes;
const bool totalDiff = totalBytes != m_totalBytes;
const bool rootDiff = hasRoot != m_hasRoot;
m_usedBytes = usedBytes;
m_totalBytes = totalBytes;
m_hasRoot = hasRoot;
if (usedDiff) {
Q_EMIT usedChanged();
}
if (totalDiff) {
Q_EMIT totalChanged();
}
if (usedDiff || totalDiff) {
Q_EMIT freeChanged();
Q_EMIT percChanged();
}
if (rootDiff) {
Q_EMIT hasRootChanged();
}
}
} // namespace caelestia::services

View file

@ -0,0 +1,46 @@
#pragma once
#include <qobject.h>
#include <qqmlintegration.h>
namespace caelestia::services {
class DiskInfo : public QObject {
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("DiskInfo is created by DiskUsage")
Q_PROPERTY(QString mount READ mount CONSTANT)
Q_PROPERTY(qreal used READ used NOTIFY usedChanged)
Q_PROPERTY(qreal total READ total NOTIFY totalChanged)
Q_PROPERTY(qreal free READ free NOTIFY freeChanged)
Q_PROPERTY(qreal perc READ perc NOTIFY percChanged)
Q_PROPERTY(bool hasRoot READ hasRoot NOTIFY hasRootChanged)
public:
DiskInfo(QString mount, quint64 usedBytes, quint64 totalBytes, bool hasRoot, QObject* parent = nullptr);
[[nodiscard]] QString mount() const;
[[nodiscard]] qreal used() const; // KiB
[[nodiscard]] qreal total() const; // KiB
[[nodiscard]] qreal free() const; // KiB
[[nodiscard]] qreal perc() const;
[[nodiscard]] bool hasRoot() const;
void update(quint64 usedBytes, quint64 totalBytes, bool hasRoot);
signals:
void usedChanged();
void totalChanged();
void freeChanged();
void percChanged();
void hasRootChanged();
private:
QString m_mount;
quint64 m_usedBytes;
quint64 m_totalBytes;
bool m_hasRoot;
};
} // namespace caelestia::services

View file

@ -0,0 +1,255 @@
#include "gpu.hpp"
#include "../Config/config.hpp"
#include "../Config/serviceconfig.hpp"
#include "sensorslib.hpp"
#include <cmath>
#include <qdir.h>
#include <qfile.h>
#include <qregularexpression.h>
namespace caelestia::services {
namespace {
constexpr const char* kTypeDetectScript =
"if command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi -L >/dev/null 2>&1; then echo NVIDIA;"
" elif ls /sys/class/drm/card*/device/gpu_busy_percent 2>/dev/null | grep -q .; then echo GENERIC;"
" else echo NONE; fi";
constexpr const char* kNameDetectScript = "nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null"
" || glxinfo -B 2>/dev/null | grep 'Device:' | cut -d':' -f2 | cut -d'(' -f1"
" || lspci 2>/dev/null | grep -i 'vga\\|3d controller\\|display' | head -1";
} // namespace
Gpu::Gpu(QObject* parent)
: TickingService(parent) {
auto* svc = caelestia::config::GlobalConfig::instance()->services();
m_userType = parseType(svc->gpuType());
QObject::connect(svc, &caelestia::config::ServiceConfig::gpuTypeChanged, this,
[this, svc] { setUserType(parseType(svc->gpuType())); });
// Detection must run before any ServiceRef appears: callers may gate the ref on
// `type !== Gpu.None`, which would otherwise deadlock the detection.
if (m_userType == Auto) {
detectTypeOnce();
}
detectNameOnce();
}
Gpu::Type Gpu::type() const {
return m_userType == Auto ? m_autoType : m_userType;
}
Gpu::Type Gpu::userType() const {
return m_userType;
}
Gpu::Type Gpu::autoType() const {
return m_autoType;
}
QString Gpu::name() const {
return m_name;
}
qreal Gpu::percentage() const {
return m_percentage;
}
qreal Gpu::temperature() const {
return m_temperature;
}
void Gpu::setUserType(Type value) {
if (value == m_userType) {
return;
}
const Type prevDerived = type();
m_userType = value;
Q_EMIT userTypeChanged();
if (type() != prevDerived) {
Q_EMIT typeChanged();
}
}
void Gpu::setAutoType(Type value) {
if (value == m_autoType) {
return;
}
const Type prevDerived = type();
m_autoType = value;
Q_EMIT autoTypeChanged();
if (type() != prevDerived) {
Q_EMIT typeChanged();
}
}
void Gpu::setName(QString value) {
if (value == m_name) {
return;
}
m_name = std::move(value);
Q_EMIT nameChanged();
}
void Gpu::tick() {
const Type t = type();
if (t == Generic) {
readGenericUsage();
readGpuTemperature();
} else if (t == Nvidia) {
startNvidiaUsage();
} else {
if (std::abs(m_percentage) > 0.0001) {
m_percentage = 0.0;
Q_EMIT percentageChanged();
}
if (std::abs(m_temperature) > 0.05) {
m_temperature = 0.0;
Q_EMIT temperatureChanged();
}
}
}
void Gpu::detectTypeOnce() {
if (m_typeProc) {
return;
}
m_typeProc = new QProcess(this);
QObject::connect(m_typeProc, &QProcess::finished, this, [this](int, QProcess::ExitStatus) {
const QByteArray out = m_typeProc->readAllStandardOutput().trimmed();
if (!out.isEmpty()) {
setAutoType(parseType(QString::fromLatin1(out)));
}
m_typeProc->deleteLater();
m_typeProc = nullptr;
});
m_typeProc->start(QStringLiteral("sh"), { QStringLiteral("-c"), QString::fromLatin1(kTypeDetectScript) });
}
void Gpu::detectNameOnce() {
if (m_nameProc) {
return;
}
m_nameProc = new QProcess(this);
QObject::connect(m_nameProc, &QProcess::finished, this, [this](int, QProcess::ExitStatus) {
const QString output = QString::fromUtf8(m_nameProc->readAllStandardOutput()).trimmed();
if (!output.isEmpty()) {
const QString lower = output.toLower();
if (lower.contains(QStringLiteral("nvidia")) || lower.contains(QStringLiteral("geforce")) ||
lower.contains(QStringLiteral("rtx")) || lower.contains(QStringLiteral("gtx")) ||
lower.contains(QStringLiteral("rx"))) {
setName(cleanName(output));
} else {
static const QRegularExpression bracketRe(QStringLiteral("\\[([^\\]]+)\\][^\\[]*$"));
const auto bracket = bracketRe.match(output);
if (bracket.hasMatch()) {
setName(cleanName(bracket.captured(1)));
} else {
static const QRegularExpression colonRe(QStringLiteral(":\\s*(.+)"));
const auto colon = colonRe.match(output);
if (colon.hasMatch()) {
setName(cleanName(colon.captured(1)));
}
}
}
}
m_nameProc->deleteLater();
m_nameProc = nullptr;
});
m_nameProc->start(QStringLiteral("sh"), { QStringLiteral("-c"), QString::fromLatin1(kNameDetectScript) });
}
void Gpu::readGenericUsage() {
const QStringList paths =
QDir(QStringLiteral("/sys/class/drm"))
.entryList(QStringList() << QStringLiteral("card*"), QDir::Dirs | QDir::NoDotAndDotDot);
qreal sum = 0.0;
int count = 0;
for (const QString& card : paths) {
QFile f(QStringLiteral("/sys/class/drm/%1/device/gpu_busy_percent").arg(card));
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
continue;
}
bool ok = false;
const qreal v = f.readAll().trimmed().toDouble(&ok);
f.close();
if (ok) {
sum += v;
++count;
}
}
const qreal newPerc = count > 0 ? sum / count / 100.0 : 0.0;
if (std::abs(newPerc - m_percentage) > 0.0001) {
m_percentage = newPerc;
Q_EMIT percentageChanged();
}
}
void Gpu::startNvidiaUsage() {
if (m_nvidiaProc) {
return;
}
m_nvidiaProc = new QProcess(this);
QObject::connect(m_nvidiaProc, &QProcess::finished, this, [this](int, QProcess::ExitStatus) {
const QByteArray out = m_nvidiaProc->readAllStandardOutput().trimmed();
m_nvidiaProc->deleteLater();
m_nvidiaProc = nullptr;
const QList<QByteArray> parts = out.split(',');
if (parts.size() < 2) {
return;
}
bool ok1 = false;
bool ok2 = false;
const qreal usage = parts.at(0).trimmed().toDouble(&ok1) / 100.0;
const qreal temp = parts.at(1).trimmed().toDouble(&ok2);
if (ok1 && std::abs(usage - m_percentage) > 0.0001) {
m_percentage = usage;
Q_EMIT percentageChanged();
}
if (ok2 && std::abs(temp - m_temperature) > 0.05) {
m_temperature = temp;
Q_EMIT temperatureChanged();
}
});
m_nvidiaProc->start(QStringLiteral("nvidia-smi"), { QStringLiteral("--query-gpu=utilization.gpu,temperature.gpu"),
QStringLiteral("--format=csv,noheader,nounits") });
}
void Gpu::readGpuTemperature() {
const auto t = sensorslib::gpuPciAverageTemp();
const qreal newTemp = t.value_or(0.0);
if (std::abs(newTemp - m_temperature) > 0.05) {
m_temperature = newTemp;
Q_EMIT temperatureChanged();
}
}
Gpu::Type Gpu::parseType(const QString& s) {
const QString u = s.trimmed().toUpper();
if (u.isEmpty()) {
return Auto;
}
if (u == QStringLiteral("NVIDIA")) {
return Nvidia;
}
if (u == QStringLiteral("GENERIC")) {
return Generic;
}
return None;
}
QString Gpu::cleanName(QString s) {
static const QRegularExpression noise(
QStringLiteral("\\(R\\)|\\(TM\\)|Graphics"), QRegularExpression::CaseInsensitiveOption);
static const QRegularExpression spaces(QStringLiteral("\\s+"));
s.replace(noise, QString());
s.replace(spaces, QStringLiteral(" "));
return s.trimmed();
}
} // namespace caelestia::services

View file

@ -0,0 +1,78 @@
#pragma once
#include "tickingservice.hpp"
#include <qprocess.h>
#include <qqmlintegration.h>
namespace caelestia::services {
class Gpu : public TickingService {
Q_OBJECT
QML_ELEMENT
QML_SINGLETON
public:
enum Type {
Auto, // user override is empty (config "") — defer to detected autoType
None, // no usable GPU
Nvidia, // queried via nvidia-smi
Generic, // queried via /sys/class/drm/card*/device/gpu_busy_percent
};
Q_ENUM(Type)
private:
Q_PROPERTY(Type type READ type NOTIFY typeChanged)
Q_PROPERTY(Type userType READ userType NOTIFY userTypeChanged)
Q_PROPERTY(Type autoType READ autoType NOTIFY autoTypeChanged)
Q_PROPERTY(QString name READ name NOTIFY nameChanged)
Q_PROPERTY(qreal percentage READ percentage NOTIFY percentageChanged)
Q_PROPERTY(qreal temperature READ temperature NOTIFY temperatureChanged)
public:
explicit Gpu(QObject* parent = nullptr);
[[nodiscard]] Type type() const;
[[nodiscard]] Type userType() const;
[[nodiscard]] Type autoType() const;
[[nodiscard]] QString name() const;
[[nodiscard]] qreal percentage() const;
[[nodiscard]] qreal temperature() const;
signals:
void typeChanged();
void userTypeChanged();
void autoTypeChanged();
void nameChanged();
void percentageChanged();
void temperatureChanged();
protected:
void tick() override;
private:
void detectTypeOnce();
void detectNameOnce();
void readGenericUsage();
void startNvidiaUsage();
void readGpuTemperature();
void setUserType(Type value);
void setAutoType(Type value);
void setName(QString value);
[[nodiscard]] static Type parseType(const QString& s);
[[nodiscard]] static QString cleanName(QString s);
Type m_userType = Auto;
Type m_autoType = None;
QString m_name;
qreal m_percentage = 0.0;
qreal m_temperature = 0.0;
QProcess* m_typeProc = nullptr;
QProcess* m_nameProc = nullptr;
QProcess* m_nvidiaProc = nullptr;
};
} // namespace caelestia::services

View file

@ -0,0 +1,64 @@
#include "memory.hpp"
#include "usagefmt.hpp"
#include <qfile.h>
#include <qregularexpression.h>
namespace caelestia::services {
Memory::Memory(QObject* parent)
: TickingService(parent) {}
qreal Memory::used() const {
return m_used;
}
qreal Memory::total() const {
return m_total;
}
qreal Memory::percentage() const {
return m_total > 0.0 ? m_used / m_total : 0.0;
}
QVariantMap Memory::formatKib(qreal kib) const {
return usagefmt::formatKib(kib);
}
void Memory::tick() {
QFile f(QStringLiteral("/proc/meminfo"));
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
return;
}
const QByteArray data = f.readAll();
f.close();
static const QRegularExpression reTotal(QStringLiteral("MemTotal: *(\\d+)"));
static const QRegularExpression reAvail(QStringLiteral("MemAvailable: *(\\d+)"));
const QString text = QString::fromLatin1(data);
const auto totalMatch = reTotal.match(text);
const auto availMatch = reAvail.match(text);
if (!totalMatch.hasMatch() || !availMatch.hasMatch()) {
return;
}
const quint64 totalKib = totalMatch.captured(1).toULongLong();
const quint64 availKib = availMatch.captured(1).toULongLong();
if (totalKib == 0) {
return;
}
const quint64 usedKib = totalKib > availKib ? totalKib - availKib : 0;
if (totalKib == m_lastTotal && usedKib == m_lastUsed) {
return;
}
m_lastTotal = totalKib;
m_lastUsed = usedKib;
m_total = static_cast<qreal>(totalKib);
m_used = static_cast<qreal>(usedKib);
Q_EMIT changed();
}
} // namespace caelestia::services

View file

@ -0,0 +1,41 @@
#pragma once
#include "tickingservice.hpp"
#include <qqmlintegration.h>
#include <qvariant.h>
namespace caelestia::services {
class Memory : public TickingService {
Q_OBJECT
QML_ELEMENT
QML_SINGLETON
Q_PROPERTY(qreal used READ used NOTIFY changed)
Q_PROPERTY(qreal total READ total NOTIFY changed)
Q_PROPERTY(qreal percentage READ percentage NOTIFY changed)
public:
explicit Memory(QObject* parent = nullptr);
[[nodiscard]] qreal used() const;
[[nodiscard]] qreal total() const;
[[nodiscard]] qreal percentage() const;
Q_INVOKABLE [[nodiscard]] QVariantMap formatKib(qreal kib) const;
signals:
void changed();
protected:
void tick() override;
private:
qreal m_used = 0.0;
qreal m_total = 1.0;
quint64 m_lastUsed = 0;
quint64 m_lastTotal = 0;
};
} // namespace caelestia::services

View file

@ -0,0 +1,166 @@
#include "sensorslib.hpp"
#include <atomic>
#include <cctype>
#include <cstdlib>
#include <cstring>
#include <mutex>
#include <qloggingcategory.h>
#include <sensors/sensors.h>
Q_LOGGING_CATEGORY(lcSensorsLib, "caelestia.services.sensorslib", QtInfoMsg)
namespace caelestia::services::sensorslib {
namespace {
std::atomic<bool> g_initOk{ false };
std::once_flag g_initFlag;
void doInit() {
if (sensors_init(nullptr) != 0) {
qCWarning(lcSensorsLib, "sensors_init failed");
g_initOk.store(false, std::memory_order_release);
return;
}
g_initOk.store(true, std::memory_order_release);
std::atexit([] {
if (g_initOk.load(std::memory_order_acquire)) {
sensors_cleanup();
}
});
}
[[nodiscard]] std::optional<double> readTempInput(const sensors_chip_name* chip, const sensors_feature* feat) {
const sensors_subfeature* sf = sensors_get_subfeature(chip, feat, SENSORS_SUBFEATURE_TEMP_INPUT);
if (!sf) {
return std::nullopt;
}
double value = 0.0;
if (sensors_get_value(chip, sf->number, &value) != 0) {
return std::nullopt;
}
return value;
}
[[nodiscard]] QByteArray featureLabel(const sensors_chip_name* chip, const sensors_feature* feat) {
char* raw = sensors_get_label(chip, feat);
if (!raw) {
return {};
}
QByteArray out(raw);
std::free(raw);
return out;
}
bool labelEquals(const QByteArray& label, const char* literal) {
return label == QByteArrayView(literal);
}
bool labelStartsWith(const QByteArray& label, const char* prefix) {
const auto n = std::strlen(prefix);
return static_cast<size_t>(label.size()) >= n && std::memcmp(label.constData(), prefix, n) == 0;
}
} // namespace
void ensureInit() {
std::call_once(g_initFlag, doInit);
}
std::optional<double> cpuPackageTemp() {
ensureInit();
if (!g_initOk.load(std::memory_order_acquire)) {
return std::nullopt;
}
std::optional<double> primary; // Package id N / Tdie
std::optional<double> fallback; // Tctl
int chipNr = 0;
while (const sensors_chip_name* chip = sensors_get_detected_chips(nullptr, &chipNr)) {
int featNr = 0;
while (const sensors_feature* feat = sensors_get_features(chip, &featNr)) {
if (feat->type != SENSORS_FEATURE_TEMP) {
continue;
}
const QByteArray label = featureLabel(chip, feat);
if (label.isEmpty()) {
continue;
}
if (labelStartsWith(label, "Package id ") || labelEquals(label, "Tdie")) {
if (auto v = readTempInput(chip, feat)) {
primary = v;
}
} else if (labelEquals(label, "Tctl")) {
if (auto v = readTempInput(chip, feat)) {
fallback = v;
}
}
}
}
return primary.has_value() ? primary : fallback;
}
std::optional<double> gpuPciAverageTemp() {
ensureInit();
if (!g_initOk.load(std::memory_order_acquire)) {
return std::nullopt;
}
double sumPrimary = 0.0;
int countPrimary = 0;
double sumFallback = 0.0;
int countFallback = 0;
int chipNr = 0;
while (const sensors_chip_name* chip = sensors_get_detected_chips(nullptr, &chipNr)) {
if (chip->bus.type != SENSORS_BUS_TYPE_PCI) {
continue;
}
int featNr = 0;
while (const sensors_feature* feat = sensors_get_features(chip, &featNr)) {
if (feat->type != SENSORS_FEATURE_TEMP) {
continue;
}
const QByteArray label = featureLabel(chip, feat);
if (label.isEmpty()) {
continue;
}
const bool tempIndexed = labelStartsWith(label, "temp") && label.size() > 4 &&
std::isdigit(static_cast<unsigned char>(label[4]));
const bool isPrimary = tempIndexed || labelEquals(label, "GPU core") || labelEquals(label, "edge");
const bool isFallback = labelEquals(label, "junction") || labelEquals(label, "mem");
if (!isPrimary && !isFallback) {
continue;
}
const auto v = readTempInput(chip, feat);
if (!v) {
continue;
}
if (isPrimary) {
sumPrimary += *v;
++countPrimary;
} else {
sumFallback += *v;
++countFallback;
}
}
}
if (countPrimary > 0) {
return sumPrimary / countPrimary;
}
if (countFallback > 0) {
return sumFallback / countFallback;
}
return std::nullopt;
}
} // namespace caelestia::services::sensorslib

View file

@ -0,0 +1,12 @@
#pragma once
#include <optional>
namespace caelestia::services::sensorslib {
void ensureInit();
[[nodiscard]] std::optional<double> cpuPackageTemp();
[[nodiscard]] std::optional<double> gpuPciAverageTemp();
} // namespace caelestia::services::sensorslib

View file

@ -0,0 +1,297 @@
#include "storage.hpp"
#include "usagefmt.hpp"
#include <algorithm>
#include <cmath>
#include <qdir.h>
#include <qfile.h>
#include <qfileinfo.h>
#include <qhash.h>
#include <qloggingcategory.h>
#include <qstorageinfo.h>
#include <sys/stat.h>
#include <sys/sysmacros.h>
Q_LOGGING_CATEGORY(lcStorage, "caelestia.services.storage", QtInfoMsg)
namespace caelestia::services {
namespace {
struct Accum {
quint64 usedBytes = 0;
quint64 totalBytes = 0;
bool hasRoot = false;
};
[[nodiscard]] QString sysfsRealPath(uint major, uint minor) {
const QString link = QStringLiteral("/sys/dev/block/%1:%2").arg(major).arg(minor);
const QString resolved = QFileInfo(link).canonicalFilePath();
return resolved;
}
[[nodiscard]] bool readDevtFromSysfs(const QString& sysfsBlockDir, uint& major, uint& minor) {
QFile f(sysfsBlockDir + QStringLiteral("/dev"));
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
return false;
}
const QByteArray line = f.readLine().trimmed();
f.close();
const qsizetype colon = line.indexOf(':');
if (colon <= 0) {
return false;
}
bool okM = false;
bool okN = false;
major = line.left(colon).toUInt(&okM);
minor = line.mid(colon + 1).toUInt(&okN);
return okM && okN;
}
QStringList resolveByDevt(uint major, uint minor, int depth = 0);
QStringList resolveAtNode(const QString& node, int depth) {
if (node.isEmpty() || depth > 8) {
return {};
}
const QFileInfo nodeInfo(node);
if (!nodeInfo.exists() || !nodeInfo.isDir()) {
return {};
}
if (QFileInfo::exists(node + QStringLiteral("/partition"))) {
const QString diskNode = nodeInfo.path();
return { QFileInfo(diskNode).fileName() };
}
const QDir slavesDir(node + QStringLiteral("/slaves"));
if (slavesDir.exists()) {
const QStringList slaves = slavesDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot);
if (!slaves.isEmpty()) {
QStringList out;
for (const QString& slave : slaves) {
uint sm = 0;
uint sn = 0;
const QString slaveDir = QStringLiteral("/sys/class/block/") + slave;
if (!readDevtFromSysfs(slaveDir, sm, sn)) {
continue;
}
const auto devs = resolveByDevt(sm, sn, depth + 1);
for (const QString& d : devs) {
if (!out.contains(d)) {
out.append(d);
}
}
}
return out;
}
}
return { nodeInfo.fileName() };
}
QStringList resolveByDevt(uint major, uint minor, int depth) {
return resolveAtNode(sysfsRealPath(major, minor), depth);
}
} // namespace
Storage::Storage(QObject* parent)
: TickingService(parent) {}
qreal Storage::percentage() const {
qreal totalUsed = 0.0;
qreal totalSize = 0.0;
for (const DiskInfo* d : m_disks) {
totalUsed += d->used();
totalSize += d->total();
}
return totalSize > 0.0 ? totalUsed / totalSize : 0.0;
}
bool Storage::sameOrder(const QList<DiskInfo*>& a, const QList<DiskInfo*>& b) {
if (a.size() != b.size()) {
return false;
}
for (qsizetype i = 0; i < a.size(); ++i) {
if (a.at(i) != b.at(i)) {
return false;
}
}
return true;
}
QQmlListProperty<DiskInfo> Storage::disksProp() {
return QQmlListProperty<DiskInfo>(this, nullptr, &Storage::disksCount, &Storage::disksAt);
}
qsizetype Storage::disksCount(QQmlListProperty<DiskInfo>* prop) {
return static_cast<Storage*>(prop->object)->m_disks.size();
}
DiskInfo* Storage::disksAt(QQmlListProperty<DiskInfo>* prop, qsizetype i) {
return static_cast<Storage*>(prop->object)->m_disks.at(i);
}
DiskInfo* Storage::manualPrimaryDisk() const {
return m_manualPrimaryDisk.data();
}
void Storage::setManualPrimaryDisk(DiskInfo* disk) {
if (m_manualPrimaryDisk.data() == disk) {
return;
}
m_manualPrimaryDisk = disk;
Q_EMIT manualPrimaryDiskChanged();
Q_EMIT primaryDiskChanged();
}
DiskInfo* Storage::primaryDisk() const {
if (auto* m = m_manualPrimaryDisk.data()) {
return m;
}
return m_disks.isEmpty() ? nullptr : m_disks.first();
}
QVariantMap Storage::formatKib(qreal kib) const {
return usagefmt::formatKib(kib);
}
bool Storage::isPseudoFs(QByteArrayView fsType) {
static constexpr const char* kPseudo[] = {
"tmpfs",
"devtmpfs",
"proc",
"sysfs",
"cgroup",
"cgroup2",
"overlay",
"squashfs",
"devpts",
"mqueue",
"ramfs",
"rpc_pipefs",
"autofs",
"configfs",
"debugfs",
"tracefs",
"securityfs",
"pstore",
"bpf",
"binfmt_misc",
"hugetlbfs",
"fusectl",
"efivarfs",
"selinuxfs",
};
for (const char* p : kPseudo) {
if (fsType == QByteArrayView(p)) {
return true;
}
}
return fsType.startsWith(QByteArrayView("fuse."));
}
QStringList Storage::resolveToPhysicalDisks(const QString& devicePath) {
if (devicePath.isEmpty() || !devicePath.startsWith(QLatin1Char('/'))) {
return {};
}
struct stat st{};
if (::stat(devicePath.toLocal8Bit().constData(), &st) != 0) {
return {};
}
if (!S_ISBLK(st.st_mode)) {
return {};
}
return resolveByDevt(major(st.st_rdev), minor(st.st_rdev));
}
void Storage::tick() {
const qreal prevPercentage = percentage();
QHash<QString, Accum> byDisk;
const auto mountedVols = QStorageInfo::mountedVolumes();
for (const QStorageInfo& v : mountedVols) {
if (!v.isReady() || !v.isValid() || v.bytesTotal() <= 0) {
continue;
}
if (isPseudoFs(QByteArrayView(v.fileSystemType()))) {
continue;
}
const QStringList disks = resolveToPhysicalDisks(QString::fromLocal8Bit(v.device()));
if (disks.isEmpty()) {
continue;
}
const auto totalBytes = static_cast<quint64>(v.bytesTotal());
const auto availBytes = static_cast<quint64>(v.bytesAvailable());
const quint64 usedBytes = totalBytes > availBytes ? totalBytes - availBytes : 0;
const bool isRoot = v.rootPath() == QStringLiteral("/");
for (const QString& d : disks) {
if (d.startsWith(QStringLiteral("zram"))) {
continue;
}
Accum& a = byDisk[d];
a.usedBytes += usedBytes;
a.totalBytes += totalBytes;
a.hasRoot = a.hasRoot || isRoot;
}
}
QHash<QString, DiskInfo*> existing;
existing.reserve(m_disks.size());
for (DiskInfo* d : std::as_const(m_disks)) {
existing.insert(d->mount(), d);
}
QList<DiskInfo*> next;
next.reserve(byDisk.size());
for (auto it = byDisk.constBegin(); it != byDisk.constEnd(); ++it) {
if (DiskInfo* survivor = existing.take(it.key())) {
survivor->update(it.value().usedBytes, it.value().totalBytes, it.value().hasRoot);
next.append(survivor);
} else {
next.append(new DiskInfo(it.key(), it.value().usedBytes, it.value().totalBytes, it.value().hasRoot, this));
}
}
std::sort(next.begin(), next.end(), [](const DiskInfo* a, const DiskInfo* b) {
if (a->hasRoot() != b->hasRoot()) {
return a->hasRoot();
}
return a->mount() < b->mount();
});
bool manualCleared = false;
if (DiskInfo* m = m_manualPrimaryDisk.data(); m && existing.contains(m->mount())) {
m_manualPrimaryDisk.clear();
manualCleared = true;
}
for (DiskInfo* stale : std::as_const(existing)) {
stale->deleteLater();
}
const bool listChanged = !sameOrder(m_disks, next);
DiskInfo* prevPrimary = primaryDisk();
m_disks = next;
if (listChanged) {
Q_EMIT disksChanged();
}
if (std::abs(percentage() - prevPercentage) > 0.0001) {
Q_EMIT percentageChanged();
}
if (manualCleared) {
Q_EMIT manualPrimaryDiskChanged();
}
if (primaryDisk() != prevPrimary) {
Q_EMIT primaryDiskChanged();
}
}
} // namespace caelestia::services

View file

@ -0,0 +1,56 @@
#pragma once
#include "diskinfo.hpp"
#include "tickingservice.hpp"
#include <qbytearrayview.h>
#include <qqmlintegration.h>
#include <qqmllist.h>
#include <qvariant.h>
namespace caelestia::services {
class Storage : public TickingService {
Q_OBJECT
QML_ELEMENT
QML_SINGLETON
Q_PROPERTY(qreal percentage READ percentage NOTIFY percentageChanged)
Q_PROPERTY(QQmlListProperty<caelestia::services::DiskInfo> disks READ disksProp NOTIFY disksChanged)
Q_PROPERTY(caelestia::services::DiskInfo* manualPrimaryDisk READ manualPrimaryDisk WRITE setManualPrimaryDisk NOTIFY
manualPrimaryDiskChanged)
Q_PROPERTY(caelestia::services::DiskInfo* primaryDisk READ primaryDisk NOTIFY primaryDiskChanged)
public:
explicit Storage(QObject* parent = nullptr);
[[nodiscard]] qreal percentage() const;
[[nodiscard]] QQmlListProperty<DiskInfo> disksProp();
[[nodiscard]] DiskInfo* manualPrimaryDisk() const;
void setManualPrimaryDisk(DiskInfo* disk);
[[nodiscard]] DiskInfo* primaryDisk() const;
Q_INVOKABLE [[nodiscard]] QVariantMap formatKib(qreal kib) const;
signals:
void disksChanged();
void percentageChanged();
void manualPrimaryDiskChanged();
void primaryDiskChanged();
protected:
void tick() override;
private:
[[nodiscard]] static QStringList resolveToPhysicalDisks(const QString& devicePath);
[[nodiscard]] static bool isPseudoFs(QByteArrayView fsType);
[[nodiscard]] static bool sameOrder(const QList<DiskInfo*>& a, const QList<DiskInfo*>& b);
static qsizetype disksCount(QQmlListProperty<DiskInfo>* prop);
static DiskInfo* disksAt(QQmlListProperty<DiskInfo>* prop, qsizetype i);
QList<DiskInfo*> m_disks;
QPointer<DiskInfo> m_manualPrimaryDisk;
};
} // namespace caelestia::services

View file

@ -0,0 +1,51 @@
#include "tickingservice.hpp"
#include "../Config/config.hpp"
#include "../Config/dashboardconfig.hpp"
namespace caelestia::services {
TickingService::TickingService(QObject* parent)
: Service(parent)
, m_timer(new QTimer(this)) {
m_timer->setSingleShot(false);
QObject::connect(m_timer, &QTimer::timeout, this, [this] {
tick();
});
auto* dash = caelestia::config::GlobalConfig::instance()->dashboard();
applyInterval(dash->resourceUpdateInterval());
QObject::connect(dash, &caelestia::config::DashboardConfig::resourceUpdateIntervalChanged, this, [this, dash] {
applyInterval(dash->resourceUpdateInterval());
});
}
int TickingService::updateInterval() const {
return m_interval;
}
void TickingService::start() {
m_running = true;
if (m_interval > 0) {
m_timer->start(m_interval);
}
tick();
}
void TickingService::stop() {
m_running = false;
m_timer->stop();
}
void TickingService::applyInterval(int ms) {
if (ms <= 0 || ms == m_interval) {
return;
}
m_interval = ms;
if (m_running) {
m_timer->start(m_interval);
}
Q_EMIT updateIntervalChanged();
}
} // namespace caelestia::services

View file

@ -0,0 +1,34 @@
#pragma once
#include "service.hpp"
namespace caelestia::services {
class TickingService : public Service {
Q_OBJECT
Q_PROPERTY(int updateInterval READ updateInterval NOTIFY updateIntervalChanged)
public:
explicit TickingService(QObject* parent = nullptr);
[[nodiscard]] int updateInterval() const;
signals:
void updateIntervalChanged();
protected:
void start() final;
void stop() final;
virtual void tick() = 0;
private:
void applyInterval(int ms);
QTimer* m_timer;
int m_interval = 1000;
bool m_running = false;
};
} // namespace caelestia::services

View file

@ -0,0 +1,69 @@
#include "usagefmt.hpp"
#include <cmath>
namespace caelestia::services::usagefmt {
namespace {
constexpr qreal kKib = 1024.0;
constexpr qreal kMib = kKib * 1024.0;
constexpr qreal kGib = kMib * 1024.0;
constexpr qreal kTib = kGib * 1024.0;
QVariantMap make(qreal value, const char* unit) {
return QVariantMap{ { QStringLiteral("value"), value }, { QStringLiteral("unit"), QString::fromLatin1(unit) } };
}
bool finitePositive(qreal v) {
return std::isfinite(v) && v >= 0.0;
}
} // namespace
QVariantMap formatKib(qreal kib) {
if (kib >= kTib) {
return make(kib / kTib, "TiB");
}
if (kib >= kGib) {
return make(kib / kGib, "GiB");
}
if (kib >= kMib) {
return make(kib / kMib, "MiB");
}
return make(kib, "KiB");
}
QVariantMap formatBytes(qreal bytes) {
if (!finitePositive(bytes)) {
return make(0.0, "B/s");
}
if (bytes < kKib) {
return make(bytes, "B/s");
}
if (bytes < kMib) {
return make(bytes / kKib, "KB/s");
}
if (bytes < kGib) {
return make(bytes / kMib, "MB/s");
}
return make(bytes / kGib, "GB/s");
}
QVariantMap formatBytesTotal(qreal bytes) {
if (!finitePositive(bytes)) {
return make(0.0, "B");
}
if (bytes < kKib) {
return make(bytes, "B");
}
if (bytes < kMib) {
return make(bytes / kKib, "KB");
}
if (bytes < kGib) {
return make(bytes / kMib, "MB");
}
return make(bytes / kGib, "GB");
}
} // namespace caelestia::services::usagefmt

View file

@ -0,0 +1,11 @@
#pragma once
#include <qvariant.h>
namespace caelestia::services::usagefmt {
[[nodiscard]] QVariantMap formatKib(qreal kib);
[[nodiscard]] QVariantMap formatBytes(qreal bytes);
[[nodiscard]] QVariantMap formatBytesTotal(qreal bytes);
} // namespace caelestia::services::usagefmt

View file

@ -1,332 +0,0 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import Caelestia.Config
Singleton {
id: root
// CPU properties
property string cpuName: ""
property real cpuPerc
property real cpuTemp
// GPU properties
readonly property string gpuType: GlobalConfig.services.gpuType.toUpperCase() || autoGpuType
property string autoGpuType: "NONE"
property string gpuName: ""
property real gpuPerc
property real gpuTemp
// Memory properties
property real memUsed
property real memTotal
readonly property real memPerc: memTotal > 0 ? memUsed / memTotal : 0
// Storage properties (aggregated)
readonly property real storagePerc: {
let totalUsed = 0;
let totalSize = 0;
for (const disk of disks) {
totalUsed += disk.used;
totalSize += disk.total;
}
return totalSize > 0 ? totalUsed / totalSize : 0;
}
// Individual disks: Array of { mount, used, total, free, perc }
property var disks: []
property var manualPrimaryDisk
readonly property var primaryDisk: manualPrimaryDisk ?? disks[0] ?? null
property real lastCpuIdle
property real lastCpuTotal
property int refCount
function cleanCpuName(name: string): string {
return name.replace(/\(R\)|\(TM\)|CPU|\d+(?:th|nd|rd|st) Gen |Core |Processor/gi, "").replace(/\s+/g, " ").trim();
}
function cleanGpuName(name: string): string {
return name.replace(/\(R\)|\(TM\)|Graphics/gi, "").replace(/\s+/g, " ").trim();
}
function formatKib(kib: real): var {
const mib = 1024;
const gib = 1024 ** 2;
const tib = 1024 ** 3;
if (kib >= tib)
return {
value: kib / tib,
unit: "TiB"
};
if (kib >= gib)
return {
value: kib / gib,
unit: "GiB"
};
if (kib >= mib)
return {
value: kib / mib,
unit: "MiB"
};
return {
value: kib,
unit: "KiB"
};
}
Timer {
running: root.refCount > 0
interval: GlobalConfig.dashboard.resourceUpdateInterval
repeat: true
triggeredOnStart: true
onTriggered: {
stat.reload();
meminfo.reload();
storage.running = true;
gpuUsage.running = true;
sensors.running = true;
}
}
// One-time CPU info detection (name)
FileView {
id: cpuinfoInit
path: "/proc/cpuinfo"
onLoaded: {
const nameMatch = text().match(/model name\s*:\s*(.+)/);
if (nameMatch)
root.cpuName = root.cleanCpuName(nameMatch[1]);
}
}
FileView {
id: stat
path: "/proc/stat"
onLoaded: {
const data = text().match(/^cpu\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/);
if (data) {
const stats = data.slice(1).map(n => parseInt(n, 10));
const total = stats.reduce((a, b) => a + b, 0);
const idle = stats[3] + (stats[4] ?? 0);
const totalDiff = total - root.lastCpuTotal;
const idleDiff = idle - root.lastCpuIdle;
root.cpuPerc = totalDiff > 0 ? (1 - idleDiff / totalDiff) : 0;
root.lastCpuTotal = total;
root.lastCpuIdle = idle;
}
}
}
FileView {
id: meminfo
path: "/proc/meminfo"
onLoaded: {
const data = text();
root.memTotal = parseInt(data.match(/MemTotal: *(\d+)/)[1], 10) || 1;
root.memUsed = (root.memTotal - parseInt(data.match(/MemAvailable: *(\d+)/)[1], 10)) || 0;
}
}
Process {
id: storage
// Get physical disks with aggregated usage from their partitions
// -J triggers JSON output. -b triggers bytes.
command: ["lsblk", "-J", "-b", "-o", "NAME,SIZE,TYPE,FSUSED,FSSIZE,MOUNTPOINT"]
stdout: StdioCollector {
onStreamFinished: {
const data = JSON.parse(text);
const diskList = [];
const seenDevices = new Set();
// Helper to recursively sum usage from children (partitions, crypt, lvm)
const aggregateUsage = dev => {
let used = 0;
let size = 0;
let isRoot = dev.mountpoint === "/" || (dev.mountpoints && dev.mountpoints.includes("/"));
if (!seenDevices.has(dev.name)) {
// lsblk returns null for empty/unformatted partitions, which parses to 0 here
used = parseInt(dev.fsused) || 0;
size = parseInt(dev.fssize) || 0;
seenDevices.add(dev.name);
}
if (dev.children) {
for (const child of dev.children) {
const stats = aggregateUsage(child);
used += stats.used;
size += stats.size;
if (stats.isRoot)
isRoot = true;
}
}
return {
used,
size,
isRoot
};
};
for (const dev of data.blockdevices) {
// Only process physical disks at the top level
if (dev.type === "disk" && !dev.name.startsWith("zram")) {
const stats = aggregateUsage(dev);
if (stats.size === 0) {
continue;
}
const total = stats.size;
const used = stats.used;
diskList.push({
mount: dev.name,
used: used / 1024 // KiB
,
total: total / 1024 // KiB
,
free: (total - used) / 1024,
perc: total > 0 ? used / total : 0,
hasRoot: stats.isRoot
});
}
}
// Sort by putting the disk with root first, then sort the rest alphabetically
root.disks = diskList.sort((a, b) => {
if (a.hasRoot && !b.hasRoot)
return -1;
if (!a.hasRoot && b.hasRoot)
return 1;
return a.mount.localeCompare(b.mount);
});
}
}
}
// GPU name detection (one-time)
Process {
id: gpuNameDetect
running: true
command: ["sh", "-c", "nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null || glxinfo -B 2>/dev/null | grep 'Device:' | cut -d':' -f2 | cut -d'(' -f1 || lspci 2>/dev/null | grep -i 'vga\\|3d controller\\|display' | head -1"]
stdout: StdioCollector {
onStreamFinished: {
const output = text.trim();
if (!output)
return;
// Check if it's from nvidia-smi (clean GPU name)
if (output.toLowerCase().includes("nvidia") || output.toLowerCase().includes("geforce") || output.toLowerCase().includes("rtx") || output.toLowerCase().includes("gtx")) {
root.gpuName = root.cleanGpuName(output);
} else if (output.toLowerCase().includes("rx")) {
root.gpuName = root.cleanGpuName(output);
} else {
// Parse lspci output: extract name from brackets or after colon
// Handles cases like [AMD/ATI] Navi 21 [Radeon RX 6800/6800 XT / 6900 XT] (rev c0)
const bracketMatch = output.match(/\[([^\]]+)\][^\[]*$/);
if (bracketMatch) {
root.gpuName = root.cleanGpuName(bracketMatch[1]);
} else {
const colonMatch = output.match(/:\s*(.+)/);
if (colonMatch)
root.gpuName = root.cleanGpuName(colonMatch[1]);
}
}
}
}
}
Process {
id: gpuTypeCheck
running: !GlobalConfig.services.gpuType
command: ["sh", "-c", "if command -v nvidia-smi &>/dev/null && nvidia-smi -L &>/dev/null; then echo NVIDIA; elif ls /sys/class/drm/card*/device/gpu_busy_percent 2>/dev/null | grep -q .; then echo GENERIC; else echo NONE; fi"]
stdout: StdioCollector {
onStreamFinished: root.autoGpuType = text.trim()
}
}
Process {
id: gpuUsage
command: root.gpuType === "GENERIC" ? ["sh", "-c", "cat /sys/class/drm/card*/device/gpu_busy_percent"] : root.gpuType === "NVIDIA" ? ["nvidia-smi", "--query-gpu=utilization.gpu,temperature.gpu", "--format=csv,noheader,nounits"] : ["echo"]
stdout: StdioCollector {
onStreamFinished: {
if (root.gpuType === "GENERIC") {
const percs = text.trim().split("\n");
const sum = percs.reduce((acc, d) => acc + parseInt(d, 10), 0);
root.gpuPerc = sum / percs.length / 100;
} else if (root.gpuType === "NVIDIA") {
const [usage, temp] = text.trim().split(",");
root.gpuPerc = parseInt(usage, 10) / 100;
root.gpuTemp = parseInt(temp, 10);
} else {
root.gpuPerc = 0;
root.gpuTemp = 0;
}
}
}
}
Process {
id: sensors
command: ["sensors"]
environment: ({
LANG: "C.UTF-8",
LC_ALL: "C.UTF-8"
})
stdout: StdioCollector {
onStreamFinished: {
let cpuTemp = text.match(/(?:Package id [0-9]+|Tdie):\s+((\+|-)[0-9.]+)(°| )C/);
if (!cpuTemp)
// If AMD Tdie pattern failed, try fallback on Tctl
cpuTemp = text.match(/Tctl:\s+((\+|-)[0-9.]+)(°| )C/);
if (cpuTemp)
root.cpuTemp = parseFloat(cpuTemp[1]);
if (root.gpuType !== "GENERIC")
return;
let eligible = false;
let sum = 0;
let count = 0;
for (const line of text.trim().split("\n")) {
if (line === "Adapter: PCI adapter")
eligible = true;
else if (line === "")
eligible = false;
else if (eligible) {
let match = line.match(/^(temp[0-9]+|GPU core|edge)+:\s+\+([0-9]+\.[0-9]+)(°| )C/);
if (!match)
// Fall back to junction/mem if GPU doesn't have edge temp (for AMD GPUs)
match = line.match(/^(junction|mem)+:\s+\+([0-9]+\.[0-9]+)(°| )C/);
if (match) {
sum += parseFloat(match[2]);
count++;
}
}
}
root.gpuTemp = count > 0 ? sum / count : 0;
}
}
}
}