mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-21 18:54:01 -04:00
879 lines
30 KiB
QML
879 lines
30 KiB
QML
pragma Singleton
|
|
pragma ComponentBehavior: Bound
|
|
|
|
import QtQuick
|
|
import Quickshell
|
|
import Quickshell.Services.UPower
|
|
import qs.Common
|
|
|
|
Singleton {
|
|
id: root
|
|
|
|
property bool suppressSound: true
|
|
property bool previousPluggedState: false
|
|
|
|
property int refCount: 0
|
|
|
|
function addRef() {
|
|
refCount++;
|
|
}
|
|
|
|
function removeRef() {
|
|
refCount = Math.max(0, refCount - 1);
|
|
}
|
|
|
|
// FreeBSD native ACPI fallback. UPower exists in ports, but on some FreeBSD
|
|
// installations it exposes no usable laptop battery. Keep UPower as the
|
|
// preferred path when it works and fall back to acpiconf(8)/sysctl(8).
|
|
readonly property bool isBSD: Qt.platform.os === "unix"
|
|
property bool freebsdBatteryAvailable: false
|
|
property bool freebsdBatteryProbeComplete: false
|
|
property real freebsdBatteryLevel: 0
|
|
property bool freebsdIsCharging: false
|
|
property bool freebsdPluggedIn: true
|
|
property real freebsdChangeRate: 0
|
|
property real freebsdFullCapacityWh: 0
|
|
property real freebsdDesignCapacityWh: 0
|
|
property string freebsdBatteryState: ""
|
|
|
|
function parseFreeBsdCapacity(value, unit) {
|
|
const n = parseFloat(value || "0");
|
|
if (!isFinite(n) || n <= 0)
|
|
return 0;
|
|
return unit === "mWh" ? n / 1000 : 0;
|
|
}
|
|
|
|
function applyFreeBsdBatteryState(output) {
|
|
const text = output || "";
|
|
const blocks = text.split(/__DMS_BATTERY_\d+__\n?/).filter(b => /Remaining capacity:/i.test(b));
|
|
let weightedPct = 0;
|
|
let weight = 0;
|
|
let pctSum = 0;
|
|
let pctCount = 0;
|
|
let totalRateW = 0;
|
|
let fullWh = 0;
|
|
let designWh = 0;
|
|
let charging = false;
|
|
let state = "";
|
|
|
|
for (const block of blocks) {
|
|
const pctMatch = block.match(/Remaining capacity:\s*(\d+(?:\.\d+)?)%/i);
|
|
const stateMatch = block.match(/State:\s*([^\n\r]+)/i);
|
|
const rateMatch = block.match(/Present rate:\s*[^\n\r]*?\((\d+(?:\.\d+)?)\s*mW\)/i) || block.match(/Present rate:\s*(\d+(?:\.\d+)?)\s*mW/i);
|
|
const fullMatch = block.match(/Last full capacity:\s*(\d+(?:\.\d+)?)\s*(mWh|mAh)/i);
|
|
const designMatch = block.match(/Design capacity:\s*(\d+(?:\.\d+)?)\s*(mWh|mAh)/i);
|
|
const pct = pctMatch ? parseFloat(pctMatch[1]) : NaN;
|
|
const full = fullMatch ? root.parseFreeBsdCapacity(fullMatch[1], fullMatch[2]) : 0;
|
|
|
|
if (isFinite(pct)) {
|
|
pctSum += pct;
|
|
pctCount++;
|
|
|
|
if (full > 0) {
|
|
weightedPct += pct * full;
|
|
weight += full;
|
|
}
|
|
}
|
|
|
|
if (rateMatch)
|
|
totalRateW += parseFloat(rateMatch[1]) / 1000;
|
|
|
|
fullWh += full;
|
|
|
|
if (designMatch)
|
|
designWh += root.parseFreeBsdCapacity(designMatch[1], designMatch[2]);
|
|
|
|
if (stateMatch) {
|
|
const thisState = stateMatch[1].trim().toLowerCase();
|
|
|
|
if (!state)
|
|
state = thisState;
|
|
|
|
if (thisState === "charging" || thisState.startsWith("charging"))
|
|
charging = true;
|
|
}
|
|
}
|
|
|
|
const acMatch = text.match(/__DMS_AC__:(\d+)/);
|
|
|
|
root.freebsdBatteryAvailable = blocks.length > 0;
|
|
root.freebsdBatteryLevel = blocks.length > 0 ? Math.max(0, Math.min(100, weight > 0 ? weightedPct / weight : (pctCount > 0 ? pctSum / pctCount : 0))) : 0;
|
|
root.freebsdIsCharging = charging;
|
|
root.freebsdPluggedIn = acMatch ? acMatch[1] === "1" : charging;
|
|
root.freebsdChangeRate = totalRateW;
|
|
root.freebsdFullCapacityWh = fullWh;
|
|
root.freebsdDesignCapacityWh = designWh;
|
|
root.freebsdBatteryState = state;
|
|
}
|
|
|
|
function refreshFreeBsdBattery() {
|
|
if (!root.isBSD || root.batteries.length > 0)
|
|
return;
|
|
|
|
const script = 'units=$(sysctl -n hw.acpi.battery.units 2>/dev/null || echo 0); i=0; while [ "$i" -lt "$units" ]; do echo "__DMS_BATTERY_${i}__"; acpiconf -i "$i" 2>/dev/null; i=$((i+1)); done; printf "__DMS_AC__:"; sysctl -n hw.acpi.acline 2>/dev/null || echo 1';
|
|
|
|
Proc.runCommand("battery-freebsd-acpi", ["nice", "-n", "10", "sh", "-c", script], (output, exitCode) => {
|
|
root.freebsdBatteryProbeComplete = true;
|
|
|
|
if (exitCode === 0)
|
|
root.applyFreeBsdBatteryState(output);
|
|
else
|
|
root.freebsdBatteryAvailable = false;
|
|
}, 0);
|
|
}
|
|
|
|
function updateFreeBsdBatteryFallback() {
|
|
if (!root.isBSD)
|
|
return;
|
|
|
|
if (root.batteries.length > 0) {
|
|
root.freebsdBatteryAvailable = false;
|
|
root.freebsdBatteryProbeComplete = false;
|
|
return;
|
|
}
|
|
|
|
if (!root.freebsdBatteryProbeComplete)
|
|
root.refreshFreeBsdBattery();
|
|
}
|
|
|
|
onBatteriesChanged: root.updateFreeBsdBatteryFallback()
|
|
|
|
onRefCountChanged: {
|
|
if (root.refCount === 1)
|
|
root.refreshFreeBsdBattery();
|
|
}
|
|
|
|
Timer {
|
|
interval: 15000
|
|
repeat: true
|
|
running: root.isBSD && root.batteries.length === 0 && root.freebsdBatteryAvailable && root.refCount > 0
|
|
|
|
onTriggered: root.refreshFreeBsdBattery()
|
|
}
|
|
|
|
Timer {
|
|
id: startupTimer
|
|
interval: 500
|
|
repeat: false
|
|
running: true
|
|
|
|
onTriggered: {
|
|
root.suppressSound = false;
|
|
root.applyPowerProfile();
|
|
}
|
|
}
|
|
|
|
Connections {
|
|
target: typeof PowerProfiles !== "undefined" ? PowerProfiles : null
|
|
|
|
function onHasPerformanceProfileChanged() {
|
|
root.applyPowerProfile();
|
|
}
|
|
}
|
|
|
|
function applyPowerProfile() {
|
|
if (!batteryAvailable)
|
|
return;
|
|
|
|
const profileValue = isPluggedIn ? SettingsData.acProfileName : SettingsData.batteryProfileName;
|
|
|
|
if (profileValue === "")
|
|
return;
|
|
|
|
const targetProfile = parseInt(profileValue);
|
|
|
|
if (isNaN(targetProfile) || PowerProfiles.profile === targetProfile)
|
|
return;
|
|
|
|
PowerProfileWatcher.applyProfile(targetProfile);
|
|
}
|
|
|
|
readonly property string preferredBatteryOverride: Quickshell.env("DMS_PREFERRED_BATTERY")
|
|
|
|
// List of laptop batteries
|
|
readonly property var batteries: UPower.devices.values.filter(dev => dev.isLaptopBattery)
|
|
|
|
readonly property var readyBatteries: batteries.filter(b => b.ready)
|
|
readonly property var stateKnownBatteries: batteries.filter(b => b.ready && b.state !== UPowerDeviceState.Unknown)
|
|
readonly property var chargeBatteries: readyBatteries.filter(b => root._hasUsableCharge(b))
|
|
readonly property var energyBatteries: readyBatteries.filter(b => b.energyCapacity > 0)
|
|
|
|
property real _lastBatteryLevel: 0
|
|
property bool _lastIsCharging: false
|
|
property real _lastChangeRate: 0
|
|
property real _lastBatteryEnergy: 0
|
|
property real _lastBatteryCapacity: 0
|
|
|
|
// UPower: 0 means unset. State can be Unknown with a valid Percentage, or Unknown with 0.
|
|
function _hasUsableCharge(dev) {
|
|
if (!dev || !dev.ready)
|
|
return false;
|
|
|
|
if (dev.percentage > 0)
|
|
return true;
|
|
|
|
return dev.energy > 0 && dev.energyCapacity > 0;
|
|
}
|
|
|
|
function _chargePercent(dev) {
|
|
if (!dev)
|
|
return 0;
|
|
|
|
if (dev.percentage > 0)
|
|
return Math.min(100, Math.round(dev.percentage * 100));
|
|
|
|
if (dev.energy > 0 && dev.energyCapacity > 0)
|
|
return Math.min(100, Math.round((dev.energy * 100) / dev.energyCapacity));
|
|
|
|
return 0;
|
|
}
|
|
|
|
function _liveChargePercent() {
|
|
if (usePreferred && preferredDevice) {
|
|
if (_hasUsableCharge(preferredDevice))
|
|
return _chargePercent(preferredDevice);
|
|
|
|
return 0;
|
|
}
|
|
|
|
if (batteries.length > 1 && _hasUsableCharge(UPower.displayDevice))
|
|
return _chargePercent(UPower.displayDevice);
|
|
|
|
if (chargeBatteries.length === 1)
|
|
return _chargePercent(chargeBatteries[0]);
|
|
|
|
if (chargeBatteries.length > 1) {
|
|
const energyPacks = chargeBatteries.filter(b => b.energy > 0 && b.energyCapacity > 0);
|
|
|
|
if (energyPacks.length === chargeBatteries.length) {
|
|
const energy = energyPacks.reduce((sum, b) => sum + b.energy, 0);
|
|
const cap = energyPacks.reduce((sum, b) => sum + b.energyCapacity, 0);
|
|
|
|
if (cap > 0)
|
|
return Math.min(100, Math.round((energy * 100) / cap));
|
|
}
|
|
|
|
return Math.min(100, Math.round(chargeBatteries.reduce((sum, b) => sum + _chargePercent(b), 0) / chargeBatteries.length));
|
|
}
|
|
|
|
if (_hasUsableCharge(UPower.displayDevice))
|
|
return _chargePercent(UPower.displayDevice);
|
|
|
|
return 0;
|
|
}
|
|
|
|
function _liveEnergy() {
|
|
if (usePreferred && preferredDevice) {
|
|
if (preferredDeviceReady && preferredDevice.energy > 0)
|
|
return preferredDevice.energy;
|
|
|
|
return 0;
|
|
}
|
|
|
|
const packs = energyBatteries.filter(b => b.energy > 0);
|
|
|
|
if (packs.length === 0)
|
|
return 0;
|
|
|
|
return packs.reduce((sum, b) => sum + b.energy, 0);
|
|
}
|
|
|
|
function _liveCapacity() {
|
|
if (usePreferred && preferredDevice) {
|
|
if (preferredDeviceReady && preferredDevice.energyCapacity > 0)
|
|
return preferredDevice.energyCapacity;
|
|
|
|
return 0;
|
|
}
|
|
|
|
if (energyBatteries.length === 0)
|
|
return 0;
|
|
|
|
return energyBatteries.reduce((sum, b) => sum + b.energyCapacity, 0);
|
|
}
|
|
|
|
readonly property bool usePreferred: preferredBatteryOverride && preferredBatteryOverride.length > 0
|
|
|
|
readonly property UPowerDevice preferredDevice: {
|
|
if (!usePreferred)
|
|
return null;
|
|
|
|
const override = preferredBatteryOverride.toLowerCase();
|
|
return batteries.find(dev => dev.nativePath.toLowerCase().includes(override)) || null;
|
|
}
|
|
|
|
readonly property bool preferredDeviceReady: preferredDevice && preferredDevice.ready
|
|
readonly property bool preferredDeviceKnown: preferredDeviceReady && preferredDevice.state !== UPowerDeviceState.Unknown
|
|
|
|
readonly property bool _hasKnownChargingState: {
|
|
if (!batteryAvailable)
|
|
return false;
|
|
|
|
if (usePreferred)
|
|
return preferredDeviceKnown;
|
|
|
|
return stateKnownBatteries.length > 0;
|
|
}
|
|
|
|
readonly property bool _currentIsCharging: {
|
|
if (!batteryAvailable)
|
|
return false;
|
|
|
|
if (usePreferred && preferredDeviceKnown)
|
|
return preferredDevice.state === UPowerDeviceState.Charging;
|
|
|
|
if (usePreferred)
|
|
return false;
|
|
|
|
return stateKnownBatteries.some(b => b.state === UPowerDeviceState.Charging);
|
|
}
|
|
|
|
// Main battery (for backward compatibility)
|
|
readonly property UPowerDevice device: {
|
|
if (usePreferred) {
|
|
if (preferredDeviceKnown)
|
|
return preferredDevice;
|
|
|
|
return stateKnownBatteries[0] || null;
|
|
}
|
|
|
|
return stateKnownBatteries[0] || readyBatteries[0] || batteries[0] || null;
|
|
}
|
|
|
|
// Whether at least one battery is available
|
|
readonly property bool batteryAvailable: (isBSD && freebsdBatteryAvailable) || batteries.length > 0
|
|
|
|
readonly property real batteryLevel: {
|
|
if (!batteryAvailable)
|
|
return 0;
|
|
|
|
if (isBSD && freebsdBatteryAvailable)
|
|
return freebsdBatteryLevel;
|
|
|
|
const live = _liveChargePercent();
|
|
return live > 0 ? live : _lastBatteryLevel;
|
|
}
|
|
|
|
readonly property bool isCharging: (isBSD && freebsdBatteryAvailable) ? freebsdIsCharging : (_hasKnownChargingState ? _currentIsCharging : _lastIsCharging)
|
|
|
|
// Is the system plugged in (Is not running on battery)
|
|
readonly property bool isPluggedIn: (isBSD && freebsdBatteryAvailable) ? freebsdPluggedIn : !UPower.onBattery
|
|
|
|
readonly property bool hasBatteryReading: batteryAvailable && batteryLevel > 0
|
|
readonly property bool isLowBattery: hasBatteryReading && batteryLevel <= SettingsData.batteryLowThreshold
|
|
readonly property bool isCriticalBattery: hasBatteryReading && batteryLevel <= SettingsData.batteryCriticalThreshold
|
|
|
|
readonly property color levelCautionColor: "#FFC107"
|
|
readonly property color levelCriticalColor: "#F44336"
|
|
|
|
readonly property color levelColor: {
|
|
if (isCharging)
|
|
return Theme.success;
|
|
|
|
if (isCriticalBattery)
|
|
return levelCriticalColor;
|
|
|
|
if (isLowBattery)
|
|
return Theme.warning;
|
|
|
|
if (batteryLevel <= SettingsData.batteryLowThreshold * 2)
|
|
return levelCautionColor;
|
|
|
|
return Theme.success;
|
|
}
|
|
|
|
property bool _hasNotifiedLowBattery: false
|
|
property bool _hasNotifiedCriticalBattery: false
|
|
property bool _hasNotifiedChargeLimit: false
|
|
|
|
function _syncLastIsCharging() {
|
|
if (_hasKnownChargingState)
|
|
_lastIsCharging = _currentIsCharging;
|
|
}
|
|
|
|
on_HasKnownChargingStateChanged: _syncLastIsCharging()
|
|
on_CurrentIsChargingChanged: _syncLastIsCharging()
|
|
|
|
Component.onCompleted: {
|
|
Qt.callLater(root.updateFreeBsdBatteryFallback);
|
|
_syncLastIsCharging();
|
|
|
|
if (batteryLevel > 0)
|
|
_lastBatteryLevel = batteryLevel;
|
|
|
|
if (batteryEnergy > 0)
|
|
_lastBatteryEnergy = batteryEnergy;
|
|
|
|
if (batteryCapacity > 0)
|
|
_lastBatteryCapacity = batteryCapacity;
|
|
}
|
|
|
|
// urgency: "critical" (red), "warning" (orange/important), or "info"
|
|
function sendAlert(title, message, urgency, icon, notificationType) {
|
|
if (notificationType === 1) {
|
|
const dbusUrgency = urgency === "critical" ? "critical" : (urgency === "warning" ? "normal" : "low");
|
|
|
|
Quickshell.execDetached(["notify-send", "-u", dbusUrgency, "-a", "DMS", "-i", icon, title, message]);
|
|
} else if (urgency === "critical") {
|
|
ToastService.showError(title, message, "", icon);
|
|
} else if (urgency === "warning") {
|
|
ToastService.showWarning(title, message, "", icon);
|
|
} else {
|
|
ToastService.showInfo(title, message, "", icon);
|
|
}
|
|
}
|
|
|
|
onBatteryLevelChanged: {
|
|
if (batteryLevel > 0)
|
|
_lastBatteryLevel = batteryLevel;
|
|
|
|
if (isCharging && batteryLevel >= SettingsData.batteryChargeLimit) {
|
|
if (!_hasNotifiedChargeLimit && SettingsData.batteryNotifyChargeLimit) {
|
|
_hasNotifiedChargeLimit = true;
|
|
|
|
sendAlert(I18n.tr("Charge Limit Reached"), I18n.tr("Battery has charged to your set limit of %1%", "charge limit notification body, %1 is the limit percentage").arg(SettingsData.batteryChargeLimit), "info", "material:battery_profile", SettingsData.batteryChargeLimitNotificationType);
|
|
}
|
|
} else if (!isCharging || batteryLevel < SettingsData.batteryChargeLimit - 2) {
|
|
_hasNotifiedChargeLimit = false;
|
|
}
|
|
|
|
if (isCharging) {
|
|
_hasNotifiedLowBattery = false;
|
|
_hasNotifiedCriticalBattery = false;
|
|
return;
|
|
}
|
|
|
|
// Critical battery check (higher priority)
|
|
if (isCriticalBattery) {
|
|
if (!_hasNotifiedCriticalBattery && SettingsData.batteryNotifyCritical) {
|
|
_hasNotifiedCriticalBattery = true;
|
|
|
|
sendAlert(I18n.tr("Critical Battery"), I18n.tr("Battery is at %1% - Connect charger immediately!", "critical battery notification body, %1 is the battery percentage").arg(batteryLevel), "critical", "material:battery_alert", SettingsData.batteryCriticalNotificationType);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (batteryLevel > SettingsData.batteryCriticalThreshold)
|
|
_hasNotifiedCriticalBattery = false;
|
|
|
|
// Low battery check
|
|
if (isLowBattery) {
|
|
if (!_hasNotifiedLowBattery && SettingsData.batteryNotifyLow) {
|
|
_hasNotifiedLowBattery = true;
|
|
|
|
sendAlert(I18n.tr("Low Battery"), I18n.tr("Battery is at %1% - Consider charging soon", "low battery notification body, %1 is the battery percentage").arg(batteryLevel), "warning", "material:battery_0_bar", SettingsData.batteryLowNotificationType);
|
|
}
|
|
|
|
if (SettingsData.batteryAutoPowerSaver && PowerProfileWatcher.available) {
|
|
if (PowerProfileWatcher.currentProfile !== PowerProfile.PowerSaver)
|
|
PowerProfileWatcher.applyProfile(PowerProfile.PowerSaver);
|
|
}
|
|
}
|
|
|
|
if (batteryLevel > SettingsData.batteryLowThreshold)
|
|
_hasNotifiedLowBattery = false;
|
|
}
|
|
|
|
onIsChargingChanged: {
|
|
// Reset average when switching states
|
|
_smoothedChangeRate = (_hasKnownChargingState && changeRate > 0) ? changeRate : 0;
|
|
|
|
_lastRateSampleTime = _smoothedChangeRate > 0 ? Date.now() : 0;
|
|
|
|
if (isCharging) {
|
|
_hasNotifiedLowBattery = false;
|
|
_hasNotifiedCriticalBattery = false;
|
|
} else {
|
|
_hasNotifiedChargeLimit = false;
|
|
}
|
|
}
|
|
|
|
onIsPluggedInChanged: {
|
|
if (suppressSound || !batteryAvailable) {
|
|
previousPluggedState = isPluggedIn;
|
|
return;
|
|
}
|
|
|
|
if (SettingsData.soundsEnabled && SettingsData.soundPluggedIn) {
|
|
if (isPluggedIn && !previousPluggedState) {
|
|
AudioService.playPowerPlugSound();
|
|
} else if (!isPluggedIn && previousPluggedState) {
|
|
AudioService.playPowerUnplugSound();
|
|
}
|
|
}
|
|
|
|
applyPowerProfile();
|
|
|
|
if (isPluggedIn) {
|
|
const dismissLow = SettingsData.batteryLowNotificationType === 1 && SettingsData.notificationTimeoutNormal === 0;
|
|
|
|
const dismissCritical = SettingsData.batteryCriticalNotificationType === 1 && SettingsData.notificationTimeoutCritical === 0;
|
|
|
|
if (dismissLow || dismissCritical) {
|
|
const lowSummary = I18n.tr("Low Battery");
|
|
const criticalSummary = I18n.tr("Critical Battery");
|
|
|
|
for (const w of NotificationService.visibleNotifications) {
|
|
if (!w || !w.notification)
|
|
continue;
|
|
|
|
const summary = w.notification.summary;
|
|
|
|
if ((dismissLow && summary === lowSummary) || (dismissCritical && summary === criticalSummary)) {
|
|
NotificationService.dismissNotification(w);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
previousPluggedState = isPluggedIn;
|
|
}
|
|
|
|
// Aggregated charge/discharge rate
|
|
readonly property real changeRate: {
|
|
if (!batteryAvailable)
|
|
return 0;
|
|
|
|
if (isBSD && freebsdBatteryAvailable) {
|
|
_lastChangeRate = freebsdChangeRate;
|
|
return _lastChangeRate;
|
|
}
|
|
|
|
if (usePreferred && preferredDeviceKnown) {
|
|
_lastChangeRate = preferredDevice.changeRate;
|
|
return _lastChangeRate;
|
|
}
|
|
|
|
if (usePreferred && preferredDevice)
|
|
return _lastChangeRate;
|
|
|
|
if (stateKnownBatteries.length === 0)
|
|
return _lastChangeRate;
|
|
|
|
const val = stateKnownBatteries.reduce((sum, b) => sum + b.changeRate, 0);
|
|
_lastChangeRate = val;
|
|
return val;
|
|
}
|
|
|
|
// Aggregated charge/discharge rate, signed: positive while charging,
|
|
// negative while draining the battery, zero when idle/fully charged.
|
|
readonly property real signedChangeRate: {
|
|
if (!batteryAvailable)
|
|
return 0;
|
|
|
|
const rate = Math.abs(changeRate);
|
|
|
|
if (!isFinite(rate) || rate < 0.05)
|
|
return 0;
|
|
|
|
return isCharging ? rate : -rate;
|
|
}
|
|
|
|
// Compact signed wattage for bar widgets, e.g. "+45W" / "-8.4W".
|
|
// Returns an empty string when there is nothing meaningful to show.
|
|
// compact drops the decimal entirely (used by vertical bars).
|
|
function formatPowerRate(compact) {
|
|
const rate = signedChangeRate;
|
|
|
|
if (rate === 0)
|
|
return "";
|
|
|
|
const magnitude = Math.abs(rate);
|
|
const value = (compact || magnitude >= 10) ? Math.round(magnitude).toString() : magnitude.toFixed(1);
|
|
|
|
return `${rate > 0 ? "+" : "-"}${value}W`;
|
|
}
|
|
|
|
// A time-weighted exponential moving average based on the aggregated charge/discharge rate
|
|
property real _smoothedChangeRate: 0
|
|
property real _lastRateSampleTime: 0
|
|
readonly property real _rateSmoothingHalfLife: 90 // in seconds
|
|
|
|
function _updateSmoothedRate() {
|
|
if (!_hasKnownChargingState || changeRate <= 0)
|
|
return;
|
|
|
|
const now = Date.now();
|
|
|
|
if (_smoothedChangeRate <= 0 || _lastRateSampleTime <= 0) {
|
|
_smoothedChangeRate = changeRate;
|
|
_lastRateSampleTime = now;
|
|
return;
|
|
}
|
|
|
|
const dt = (now - _lastRateSampleTime) / 1000;
|
|
_lastRateSampleTime = now;
|
|
|
|
if (dt <= 0)
|
|
return;
|
|
|
|
const tau = _rateSmoothingHalfLife / Math.LN2;
|
|
const alpha = 1 - Math.exp(-dt / tau);
|
|
_smoothedChangeRate += alpha * (changeRate - _smoothedChangeRate);
|
|
}
|
|
|
|
onChangeRateChanged: _updateSmoothedRate()
|
|
|
|
onBatteryAvailableChanged: {
|
|
if (!batteryAvailable) {
|
|
_smoothedChangeRate = 0;
|
|
_lastRateSampleTime = 0;
|
|
}
|
|
}
|
|
|
|
// Aggregated battery health
|
|
readonly property string batteryHealth: {
|
|
if (!batteryAvailable)
|
|
return "N/A";
|
|
|
|
if (isBSD && freebsdBatteryAvailable) {
|
|
if (freebsdDesignCapacityWh > 0 && freebsdFullCapacityWh > 0)
|
|
return `${Math.round(Math.min(100, freebsdFullCapacityWh * 100 / freebsdDesignCapacityWh))}%`;
|
|
|
|
return "N/A";
|
|
}
|
|
|
|
if (usePreferred && preferredDeviceReady && preferredDevice.healthSupported)
|
|
return `${Math.round(preferredDevice.healthPercentage)}%`;
|
|
|
|
const validBatteries = readyBatteries.filter(b => b.healthSupported && b.healthPercentage > 0);
|
|
|
|
if (validBatteries.length === 0)
|
|
return "N/A";
|
|
|
|
const avgHealth = validBatteries.reduce((sum, b) => sum + b.healthPercentage, 0) / validBatteries.length;
|
|
|
|
return `${Math.round(avgHealth)}%`;
|
|
}
|
|
|
|
readonly property real batteryEnergy: {
|
|
if (!batteryAvailable)
|
|
return 0;
|
|
|
|
if (isBSD && freebsdBatteryAvailable && freebsdFullCapacityWh > 0)
|
|
return freebsdFullCapacityWh * freebsdBatteryLevel / 100;
|
|
|
|
const live = _liveEnergy();
|
|
return live > 0 ? live : _lastBatteryEnergy;
|
|
}
|
|
|
|
onBatteryEnergyChanged: {
|
|
if (batteryEnergy > 0)
|
|
_lastBatteryEnergy = batteryEnergy;
|
|
}
|
|
|
|
readonly property real batteryCapacity: {
|
|
if (!batteryAvailable)
|
|
return 0;
|
|
|
|
if (isBSD && freebsdBatteryAvailable)
|
|
return freebsdFullCapacityWh;
|
|
|
|
const live = _liveCapacity();
|
|
return live > 0 ? live : _lastBatteryCapacity;
|
|
}
|
|
|
|
onBatteryCapacityChanged: {
|
|
if (batteryCapacity > 0)
|
|
_lastBatteryCapacity = batteryCapacity;
|
|
}
|
|
|
|
function translateBatteryState(state) {
|
|
switch (state) {
|
|
case UPowerDeviceState.Charging:
|
|
return I18n.tr("Charging", "battery status");
|
|
case UPowerDeviceState.Discharging:
|
|
return I18n.tr("Discharging", "battery status");
|
|
case UPowerDeviceState.Empty:
|
|
return I18n.tr("Empty", "battery status");
|
|
case UPowerDeviceState.FullyCharged:
|
|
return I18n.tr("Fully Charged", "battery status");
|
|
case UPowerDeviceState.PendingCharge:
|
|
return I18n.tr("Pending Charge", "battery status");
|
|
case UPowerDeviceState.PendingDischarge:
|
|
return I18n.tr("Pending Discharge", "battery status");
|
|
default:
|
|
return I18n.tr("Unknown", "battery status");
|
|
}
|
|
}
|
|
|
|
readonly property string batteryStatus: {
|
|
if (!batteryAvailable)
|
|
return I18n.tr("No battery", "battery status");
|
|
|
|
if (isBSD && freebsdBatteryAvailable) {
|
|
if (isCharging)
|
|
return I18n.tr("Charging", "battery status");
|
|
|
|
return isPluggedIn ? I18n.tr("Plugged in", "battery status") : I18n.tr("Discharging", "battery status");
|
|
}
|
|
|
|
if (stateKnownBatteries.length === 0) {
|
|
if (isCharging)
|
|
return I18n.tr("Charging", "battery status");
|
|
|
|
return isPluggedIn ? I18n.tr("Plugged in", "battery status") : I18n.tr("Discharging", "battery status");
|
|
}
|
|
|
|
if (isCharging && !stateKnownBatteries.some(b => b.changeRate > 0))
|
|
return I18n.tr("Plugged in", "battery status");
|
|
|
|
const states = stateKnownBatteries.map(b => b.state);
|
|
|
|
if (states.every(s => s === states[0]))
|
|
return translateBatteryState(states[0]);
|
|
|
|
return isCharging ? I18n.tr("Charging", "battery status") : (isPluggedIn ? I18n.tr("Plugged in", "battery status") : I18n.tr("Discharging", "battery status"));
|
|
}
|
|
|
|
readonly property bool suggestPowerSaver: false
|
|
|
|
readonly property var peripheralDevices: refCount === 0 ? [] : UPower.devices.values.filter(dev => dev && dev.ready && !dev.isLaptopBattery && peripheralIcon(dev.type) !== "").map(dev => ({
|
|
"name": dev.model || UPowerDeviceType.toString(dev.type),
|
|
"percentage": Math.round(dev.percentage * 100),
|
|
"type": dev.type,
|
|
"icon": peripheralIcon(dev.type),
|
|
"charging": dev.state === UPowerDeviceState.Charging
|
|
}))
|
|
|
|
readonly property var bluetoothDevices: {
|
|
if (refCount === 0)
|
|
return [];
|
|
const bluetoothTypes = [UPowerDeviceType.BluetoothGeneric, UPowerDeviceType.Headphones, UPowerDeviceType.Headset, UPowerDeviceType.Keyboard, UPowerDeviceType.Mouse, UPowerDeviceType.Speakers];
|
|
|
|
return peripheralDevices.filter(dev => bluetoothTypes.includes(dev.type));
|
|
}
|
|
|
|
function peripheralIcon(type) {
|
|
switch (type) {
|
|
case UPowerDeviceType.BluetoothGeneric:
|
|
return "bluetooth";
|
|
case UPowerDeviceType.Headphones:
|
|
return "headphones";
|
|
case UPowerDeviceType.Headset:
|
|
return "headset_mic";
|
|
case UPowerDeviceType.Speakers:
|
|
return "speaker";
|
|
case UPowerDeviceType.Keyboard:
|
|
return "keyboard";
|
|
case UPowerDeviceType.Mouse:
|
|
return "mouse";
|
|
case UPowerDeviceType.Touchpad:
|
|
return "touchpad_mouse";
|
|
case UPowerDeviceType.Pen:
|
|
return "stylus";
|
|
case UPowerDeviceType.GamingInput:
|
|
return "sports_esports";
|
|
case UPowerDeviceType.Phone:
|
|
return "smartphone";
|
|
case UPowerDeviceType.Tablet:
|
|
return "tablet";
|
|
case UPowerDeviceType.MediaPlayer:
|
|
return "media_output";
|
|
case UPowerDeviceType.Wearable:
|
|
return "watch";
|
|
default:
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function estimatedSeconds() {
|
|
if (!batteryAvailable)
|
|
return 0;
|
|
|
|
const rate = _smoothedChangeRate > 0 ? _smoothedChangeRate : changeRate;
|
|
|
|
const totalTime = isCharging ? ((batteryCapacity - batteryEnergy) / rate) : (batteryEnergy / rate);
|
|
|
|
const seconds = Math.abs(totalTime * 3600);
|
|
|
|
if (!seconds || seconds <= 0 || seconds > 86400)
|
|
return 0;
|
|
|
|
return seconds;
|
|
}
|
|
|
|
function formatDuration(seconds) {
|
|
const hours = Math.floor(seconds / 3600);
|
|
const minutes = Math.floor((seconds % 3600) / 60);
|
|
|
|
return hours > 0 ? I18n.tr("%1h %2m", "battery time remaining").arg(hours).arg(minutes) : I18n.tr("%1m", "battery time remaining").arg(minutes);
|
|
}
|
|
|
|
function formatTimeRemaining() {
|
|
const seconds = estimatedSeconds();
|
|
|
|
if (!seconds)
|
|
return "Unknown";
|
|
|
|
return formatDuration(seconds);
|
|
}
|
|
|
|
function formatEstimatedTime() {
|
|
const seconds = estimatedSeconds();
|
|
|
|
if (!seconds)
|
|
return "";
|
|
|
|
const target = new Date(Date.now() + seconds * 1000);
|
|
const use24Hour = SettingsData.use24HourClock !== false;
|
|
|
|
return target.toLocaleTimeString(Qt.locale(), use24Hour ? "HH:mm" : "h:mm AP");
|
|
}
|
|
|
|
function getBatteryIcon() {
|
|
if (!batteryAvailable)
|
|
return "power";
|
|
|
|
if (isCharging) {
|
|
if (batteryLevel >= 90)
|
|
return "battery_charging_full";
|
|
if (batteryLevel >= 80)
|
|
return "battery_charging_90";
|
|
if (batteryLevel >= 60)
|
|
return "battery_charging_80";
|
|
if (batteryLevel >= 50)
|
|
return "battery_charging_60";
|
|
if (batteryLevel >= 30)
|
|
return "battery_charging_50";
|
|
if (batteryLevel >= 20)
|
|
return "battery_charging_30";
|
|
|
|
return "battery_charging_20";
|
|
}
|
|
|
|
if (isPluggedIn) {
|
|
if (batteryLevel >= 90)
|
|
return "battery_charging_full";
|
|
if (batteryLevel >= 80)
|
|
return "battery_charging_90";
|
|
if (batteryLevel >= 60)
|
|
return "battery_charging_80";
|
|
if (batteryLevel >= 50)
|
|
return "battery_charging_60";
|
|
if (batteryLevel >= 30)
|
|
return "battery_charging_50";
|
|
if (batteryLevel >= 20)
|
|
return "battery_charging_30";
|
|
|
|
return "battery_charging_20";
|
|
}
|
|
|
|
if (batteryLevel >= 95)
|
|
return "battery_full";
|
|
if (batteryLevel >= 85)
|
|
return "battery_6_bar";
|
|
if (batteryLevel >= 70)
|
|
return "battery_5_bar";
|
|
if (batteryLevel >= 55)
|
|
return "battery_4_bar";
|
|
if (batteryLevel >= 40)
|
|
return "battery_3_bar";
|
|
if (batteryLevel >= 25)
|
|
return "battery_2_bar";
|
|
|
|
return "battery_1_bar";
|
|
}
|
|
}
|