diff --git a/modules/controlcenter/dashboard/PerformanceSection.qml b/modules/controlcenter/dashboard/PerformanceSection.qml index 9f14cc71..12562834 100644 --- a/modules/controlcenter/dashboard/PerformanceSection.qml +++ b/modules/controlcenter/dashboard/PerformanceSection.qml @@ -4,6 +4,7 @@ import QtQuick import QtQuick.Layouts import Quickshell.Services.UPower import Caelestia.Config +import Caelestia.Services import qs.components import qs.components.controls import qs.services @@ -12,8 +13,8 @@ SectionContainer { id: root required property var rootItem - // GPU toggle is hidden when gpuType is "NONE" (no GPU data available) - readonly property bool gpuAvailable: SystemUsage.gpuType !== "NONE" + // GPU toggle is hidden when type is Gpu.None (no GPU data available) + readonly property bool gpuAvailable: Gpu.type !== Gpu.None // Battery toggle is hidden when no laptop battery is present readonly property bool batteryAvailable: UPower.displayDevice.isLaptopBattery diff --git a/modules/dashboard/Performance.qml b/modules/dashboard/Performance.qml index b405ddc3..cb07d170 100644 --- a/modules/dashboard/Performance.qml +++ b/modules/dashboard/Performance.qml @@ -5,8 +5,8 @@ import QtQuick.Layouts import Quickshell.Services.UPower import Caelestia.Config import Caelestia.Internal +import Caelestia.Services import qs.components -import qs.components.misc import qs.services Item { @@ -29,7 +29,7 @@ Item { height: 350 radius: Tokens.rounding.extraLarge 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 { anchors.centerIn: parent @@ -66,8 +66,12 @@ Item { spacing: Tokens.spacing.medium visible: !placeholder.visible - Ref { - service: SystemUsage + ServiceRef { + service: root.Config.dashboard.performance.showCpu ? Cpu : null + } + + ServiceRef { + service: root.Config.dashboard.performance.showGpu && Gpu.type !== Gpu.None ? Gpu : null } ColumnLayout { @@ -79,27 +83,27 @@ Item { RowLayout { Layout.fillWidth: true 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 { Layout.fillWidth: true visible: Config.dashboard.performance.showCpu icon: "memory" label: qsTr("CPU") - subLabel: SystemUsage.cpuName - usage: SystemUsage.cpuPerc - temperature: SystemUsage.cpuTemp + subLabel: Cpu.name + usage: Cpu.percentage + temperature: Cpu.temperature accent: Colours.palette.m3primary } HeroCard { Layout.fillWidth: true - visible: Config.dashboard.performance.showGpu && SystemUsage.gpuType !== "NONE" + visible: Config.dashboard.performance.showGpu && Gpu.type !== Gpu.None icon: "desktop_windows" label: qsTr("GPU") - subLabel: SystemUsage.gpuName - usage: SystemUsage.gpuPerc - temperature: SystemUsage.gpuTemp + subLabel: Gpu.name + usage: Gpu.percentage + temperature: Gpu.temperature accent: Colours.palette.m3secondary } } diff --git a/modules/dashboard/dash/Resources.qml b/modules/dashboard/dash/Resources.qml index 659de7b0..898f69a4 100644 --- a/modules/dashboard/dash/Resources.qml +++ b/modules/dashboard/dash/Resources.qml @@ -1,9 +1,9 @@ import QtQuick import QtQuick.Layouts import Caelestia.Config +import Caelestia.Services import qs.components import qs.components.controls -import qs.components.misc import qs.services Item { @@ -14,8 +14,16 @@ Item { implicitWidth: layout.implicitWidth + layout.anchors.margins * 2 - Ref { - service: SystemUsage + ServiceRef { + service: Cpu + } + + ServiceRef { + service: Memory + } + + ServiceRef { + service: Storage } ColumnLayout { @@ -29,18 +37,18 @@ Item { Resource { icon: "memory" - value: SystemUsage.cpuPerc + value: Cpu.percentage } Resource { icon: "memory_alt" - value: SystemUsage.memPerc + value: Memory.percentage fgColour: Colours.palette.m3tertiary } Resource { icon: "hard_disk" - value: SystemUsage.storagePerc + value: Storage.percentage fgColour: Colours.palette.m3secondary } } diff --git a/modules/dashboard/performance/MemoryCard.qml b/modules/dashboard/performance/MemoryCard.qml index c4f8a56d..4f77c096 100644 --- a/modules/dashboard/performance/MemoryCard.qml +++ b/modules/dashboard/performance/MemoryCard.qml @@ -1,6 +1,7 @@ import QtQuick import QtQuick.Layouts import Caelestia.Config +import Caelestia.Services import qs.components import qs.components.controls import qs.services @@ -16,6 +17,10 @@ StyledRect { implicitWidth: layout.implicitWidth + Tokens.padding.extraLargeIncreased * 2 implicitHeight: layout.implicitHeight + Tokens.padding.large * 2 + ServiceRef { + service: Memory + } + ColumnLayout { id: layout @@ -47,7 +52,7 @@ StyledRect { sweepAngle: 270 fgColour: root.accent - value: SystemUsage.memPerc + value: Memory.percentage Behavior on clampedVal { Anim {} @@ -62,7 +67,7 @@ StyledRect { StyledText { 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() color: root.accent } @@ -79,8 +84,8 @@ StyledRect { StyledText { Layout.alignment: Qt.AlignHCenter text: { - const usedFmt = SystemUsage.formatKib(SystemUsage.memUsed); - const totalFmt = SystemUsage.formatKib(SystemUsage.memTotal); + const usedFmt = Memory.formatKib(Memory.used); + const totalFmt = Memory.formatKib(Memory.total); return `${usedFmt.value.toFixed(1)} / ${Math.floor(totalFmt.value)} ${totalFmt.unit}`; } font: Tokens.font.body.medium diff --git a/modules/dashboard/performance/StorageCard.qml b/modules/dashboard/performance/StorageCard.qml index 92b0d7a2..87c05f8c 100644 --- a/modules/dashboard/performance/StorageCard.qml +++ b/modules/dashboard/performance/StorageCard.qml @@ -2,6 +2,7 @@ import QtQuick import QtQuick.Layouts import Quickshell import Caelestia.Config +import Caelestia.Services import qs.components import qs.components.controls import qs.services @@ -10,7 +11,7 @@ StyledRect { id: root 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 radius: Tokens.rounding.extraExtraLarge @@ -18,6 +19,10 @@ StyledRect { implicitWidth: layout.implicitWidth + layout.anchors.margins * 2 implicitHeight: layout.implicitHeight + Tokens.padding.large * 2 + ServiceRef { + service: Storage + } + ColumnLayout { id: layout @@ -83,11 +88,11 @@ StyledRect { StyledText { text: { - if (!SystemUsage.primaryDisk) + if (!Storage.primaryDisk) return qsTr("No disks detected"); - const usedFmt = SystemUsage.formatKib(SystemUsage.primaryDisk.used); - const totalFmt = SystemUsage.formatKib(SystemUsage.primaryDisk.total); + const usedFmt = Storage.formatKib(Storage.primaryDisk.used); + const totalFmt = Storage.formatKib(Storage.primaryDisk.total); return `${usedFmt.value.toFixed(1)} / ${Math.floor(totalFmt.value)} ${totalFmt.unit}`; } font: Tokens.font.body.large @@ -100,20 +105,20 @@ StyledRect { Layout.alignment: Qt.AlignHCenter type: SplitButton.Tonal - disabled: !SystemUsage.disks.length + disabled: !Storage.disks.length fallbackIcon: "storage" fallbackText: qsTr("No disks") menuOnTop: true minLeftWidth: row.implicitWidth * 0.6 menuItems: disks.instances - active: menuItems.find(m => m.modelData === SystemUsage.primaryDisk) ?? menuItems[0] ?? null - menu.onItemSelected: item => SystemUsage.manualPrimaryDisk = (item as DiskItem).modelData + active: menuItems.find(m => m.modelData === Storage.primaryDisk) ?? menuItems[0] ?? null + menu.onItemSelected: item => Storage.manualPrimaryDisk = (item as DiskItem).modelData Variants { id: disks - model: SystemUsage.disks + model: Storage.disks DiskItem {} } @@ -123,7 +128,7 @@ StyledRect { component DiskItem: MenuItem { required property var modelData - icon: modelData === SystemUsage.primaryDisk ? "check" : "" + icon: modelData === Storage.primaryDisk ? "check" : "" text: modelData.mount activeIcon: "storage" } diff --git a/modules/lock/Resources.qml b/modules/lock/Resources.qml index 630266a4..093c6dab 100644 --- a/modules/lock/Resources.qml +++ b/modules/lock/Resources.qml @@ -1,9 +1,9 @@ import QtQuick import QtQuick.Layouts import Caelestia.Config +import Caelestia.Services import qs.components import qs.components.controls -import qs.components.misc import qs.services GridLayout { @@ -18,35 +18,43 @@ GridLayout { rows: 2 columns: 2 - Ref { - service: SystemUsage + ServiceRef { + service: Cpu + } + + ServiceRef { + service: Memory + } + + ServiceRef { + service: Storage } Resource { Layout.topMargin: Tokens.padding.large icon: "memory" - value: SystemUsage.cpuPerc + value: Cpu.percentage colour: Colours.palette.m3primary } Resource { Layout.topMargin: Tokens.padding.large icon: "thermostat" - value: Math.min(1, SystemUsage.cpuTemp / 90) + value: Math.min(1, Cpu.temperature / 90) colour: Colours.palette.m3secondary } Resource { Layout.bottomMargin: Tokens.padding.large icon: "memory_alt" - value: SystemUsage.memPerc + value: Memory.percentage colour: Colours.palette.m3secondary } Resource { Layout.bottomMargin: Tokens.padding.large icon: "hard_disk" - value: SystemUsage.storagePerc + value: Storage.percentage colour: Colours.palette.m3tertiary } diff --git a/plugin/src/Caelestia/CMakeLists.txt b/plugin/src/Caelestia/CMakeLists.txt index 6f4ca980..28355a50 100644 --- a/plugin/src/Caelestia/CMakeLists.txt +++ b/plugin/src/Caelestia/CMakeLists.txt @@ -8,6 +8,16 @@ if(NOT Cava_FOUND) pkg_check_modules(Cava IMPORTED_TARGET cava REQUIRED) 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") qt_standard_project_setup(REQUIRES 6.9) diff --git a/plugin/src/Caelestia/Services/CMakeLists.txt b/plugin/src/Caelestia/Services/CMakeLists.txt index 8ce868b5..2745ac79 100644 --- a/plugin/src/Caelestia/Services/CMakeLists.txt +++ b/plugin/src/Caelestia/Services/CMakeLists.txt @@ -1,14 +1,25 @@ qml_module(caelestia-services URI Caelestia.Services SOURCES - service.hpp service.cpp - serviceref.hpp serviceref.cpp - beattracker.hpp beattracker.cpp - audiocollector.hpp audiocollector.cpp - audioprovider.hpp audioprovider.cpp - cavaprovider.hpp cavaprovider.cpp + service.cpp + serviceref.cpp + beattracker.cpp + audiocollector.cpp + audioprovider.cpp + cavaprovider.cpp + tickingservice.cpp + sensorslib.cpp + usagefmt.cpp + cpu.cpp + gpu.cpp + memory.cpp + diskinfo.cpp + storage.cpp LIBRARIES PkgConfig::Pipewire PkgConfig::Aubio PkgConfig::Cava + Sensors::Sensors + caelestia-config + caelestia-internal ) diff --git a/plugin/src/Caelestia/Services/cpu.cpp b/plugin/src/Caelestia/Services/cpu.cpp new file mode 100644 index 00000000..2872508a --- /dev/null +++ b/plugin/src/Caelestia/Services/cpu.cpp @@ -0,0 +1,117 @@ +#include "cpu.hpp" + +#include "sensorslib.hpp" + +#include +#include +#include + +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(idleDiff) / static_cast(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 diff --git a/plugin/src/Caelestia/Services/cpu.hpp b/plugin/src/Caelestia/Services/cpu.hpp new file mode 100644 index 00000000..72db08ff --- /dev/null +++ b/plugin/src/Caelestia/Services/cpu.hpp @@ -0,0 +1,48 @@ +#pragma once + +#include "tickingservice.hpp" + +#include + +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 diff --git a/plugin/src/Caelestia/Services/diskinfo.cpp b/plugin/src/Caelestia/Services/diskinfo.cpp new file mode 100644 index 00000000..7da2a6cb --- /dev/null +++ b/plugin/src/Caelestia/Services/diskinfo.cpp @@ -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(m_usedBytes) / kKib; +} + +qreal DiskInfo::total() const { + return static_cast(m_totalBytes) / kKib; +} + +qreal DiskInfo::free() const { + const quint64 freeBytes = m_totalBytes > m_usedBytes ? m_totalBytes - m_usedBytes : 0; + return static_cast(freeBytes) / kKib; +} + +qreal DiskInfo::perc() const { + return m_totalBytes > 0 ? static_cast(m_usedBytes) / static_cast(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 diff --git a/plugin/src/Caelestia/Services/diskinfo.hpp b/plugin/src/Caelestia/Services/diskinfo.hpp new file mode 100644 index 00000000..331d87b6 --- /dev/null +++ b/plugin/src/Caelestia/Services/diskinfo.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include +#include + +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 diff --git a/plugin/src/Caelestia/Services/gpu.cpp b/plugin/src/Caelestia/Services/gpu.cpp new file mode 100644 index 00000000..af1bdda4 --- /dev/null +++ b/plugin/src/Caelestia/Services/gpu.cpp @@ -0,0 +1,255 @@ +#include "gpu.hpp" + +#include "../Config/config.hpp" +#include "../Config/serviceconfig.hpp" +#include "sensorslib.hpp" + +#include +#include +#include +#include + +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 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 diff --git a/plugin/src/Caelestia/Services/gpu.hpp b/plugin/src/Caelestia/Services/gpu.hpp new file mode 100644 index 00000000..7111e554 --- /dev/null +++ b/plugin/src/Caelestia/Services/gpu.hpp @@ -0,0 +1,78 @@ +#pragma once + +#include "tickingservice.hpp" + +#include +#include + +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 diff --git a/plugin/src/Caelestia/Services/memory.cpp b/plugin/src/Caelestia/Services/memory.cpp new file mode 100644 index 00000000..d3bcf293 --- /dev/null +++ b/plugin/src/Caelestia/Services/memory.cpp @@ -0,0 +1,64 @@ +#include "memory.hpp" + +#include "usagefmt.hpp" + +#include +#include + +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(totalKib); + m_used = static_cast(usedKib); + Q_EMIT changed(); +} + +} // namespace caelestia::services diff --git a/plugin/src/Caelestia/Services/memory.hpp b/plugin/src/Caelestia/Services/memory.hpp new file mode 100644 index 00000000..4a77a755 --- /dev/null +++ b/plugin/src/Caelestia/Services/memory.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include "tickingservice.hpp" + +#include +#include + +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 diff --git a/plugin/src/Caelestia/Services/sensorslib.cpp b/plugin/src/Caelestia/Services/sensorslib.cpp new file mode 100644 index 00000000..7b57731b --- /dev/null +++ b/plugin/src/Caelestia/Services/sensorslib.cpp @@ -0,0 +1,166 @@ +#include "sensorslib.hpp" + +#include +#include +#include +#include +#include +#include +#include + +Q_LOGGING_CATEGORY(lcSensorsLib, "caelestia.services.sensorslib", QtInfoMsg) + +namespace caelestia::services::sensorslib { + +namespace { + +std::atomic 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 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(label.size()) >= n && std::memcmp(label.constData(), prefix, n) == 0; +} + +} // namespace + +void ensureInit() { + std::call_once(g_initFlag, doInit); +} + +std::optional cpuPackageTemp() { + ensureInit(); + if (!g_initOk.load(std::memory_order_acquire)) { + return std::nullopt; + } + + std::optional primary; // Package id N / Tdie + std::optional 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 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(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 diff --git a/plugin/src/Caelestia/Services/sensorslib.hpp b/plugin/src/Caelestia/Services/sensorslib.hpp new file mode 100644 index 00000000..8c305a6f --- /dev/null +++ b/plugin/src/Caelestia/Services/sensorslib.hpp @@ -0,0 +1,12 @@ +#pragma once + +#include + +namespace caelestia::services::sensorslib { + +void ensureInit(); + +[[nodiscard]] std::optional cpuPackageTemp(); +[[nodiscard]] std::optional gpuPciAverageTemp(); + +} // namespace caelestia::services::sensorslib diff --git a/plugin/src/Caelestia/Services/storage.cpp b/plugin/src/Caelestia/Services/storage.cpp new file mode 100644 index 00000000..c05ca605 --- /dev/null +++ b/plugin/src/Caelestia/Services/storage.cpp @@ -0,0 +1,297 @@ +#include "storage.hpp" + +#include "usagefmt.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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& a, const QList& 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 Storage::disksProp() { + return QQmlListProperty(this, nullptr, &Storage::disksCount, &Storage::disksAt); +} + +qsizetype Storage::disksCount(QQmlListProperty* prop) { + return static_cast(prop->object)->m_disks.size(); +} + +DiskInfo* Storage::disksAt(QQmlListProperty* prop, qsizetype i) { + return static_cast(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 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(v.bytesTotal()); + const auto availBytes = static_cast(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 existing; + existing.reserve(m_disks.size()); + for (DiskInfo* d : std::as_const(m_disks)) { + existing.insert(d->mount(), d); + } + + QList 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 diff --git a/plugin/src/Caelestia/Services/storage.hpp b/plugin/src/Caelestia/Services/storage.hpp new file mode 100644 index 00000000..a88ac5ab --- /dev/null +++ b/plugin/src/Caelestia/Services/storage.hpp @@ -0,0 +1,56 @@ +#pragma once + +#include "diskinfo.hpp" +#include "tickingservice.hpp" + +#include +#include +#include +#include + +namespace caelestia::services { + +class Storage : public TickingService { + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + Q_PROPERTY(qreal percentage READ percentage NOTIFY percentageChanged) + Q_PROPERTY(QQmlListProperty 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 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& a, const QList& b); + + static qsizetype disksCount(QQmlListProperty* prop); + static DiskInfo* disksAt(QQmlListProperty* prop, qsizetype i); + + QList m_disks; + QPointer m_manualPrimaryDisk; +}; + +} // namespace caelestia::services diff --git a/plugin/src/Caelestia/Services/tickingservice.cpp b/plugin/src/Caelestia/Services/tickingservice.cpp new file mode 100644 index 00000000..78454f5e --- /dev/null +++ b/plugin/src/Caelestia/Services/tickingservice.cpp @@ -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 diff --git a/plugin/src/Caelestia/Services/tickingservice.hpp b/plugin/src/Caelestia/Services/tickingservice.hpp new file mode 100644 index 00000000..721b35d0 --- /dev/null +++ b/plugin/src/Caelestia/Services/tickingservice.hpp @@ -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 diff --git a/plugin/src/Caelestia/Services/usagefmt.cpp b/plugin/src/Caelestia/Services/usagefmt.cpp new file mode 100644 index 00000000..7c6e220e --- /dev/null +++ b/plugin/src/Caelestia/Services/usagefmt.cpp @@ -0,0 +1,69 @@ +#include "usagefmt.hpp" + +#include + +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 diff --git a/plugin/src/Caelestia/Services/usagefmt.hpp b/plugin/src/Caelestia/Services/usagefmt.hpp new file mode 100644 index 00000000..17d369d0 --- /dev/null +++ b/plugin/src/Caelestia/Services/usagefmt.hpp @@ -0,0 +1,11 @@ +#pragma once + +#include + +namespace caelestia::services::usagefmt { + +[[nodiscard]] QVariantMap formatKib(qreal kib); +[[nodiscard]] QVariantMap formatBytes(qreal bytes); +[[nodiscard]] QVariantMap formatBytesTotal(qreal bytes); + +} // namespace caelestia::services::usagefmt diff --git a/services/SystemUsage.qml b/services/SystemUsage.qml deleted file mode 100644 index cb1ff346..00000000 --- a/services/SystemUsage.qml +++ /dev/null @@ -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; - } - } - } -}