Skip to content

Commit b38bf2d

Browse files
authored
feat(power): add battery charge threshold controls (#3829)
* feat(power): add battery charge limit controls * refine battery protection controls * fix(power): harden battery charge limit handling * refine charge threshold presentation * fix(power): prevent charge threshold UI flicker * fix(power): hide unavailable charge limits * refine disabled charge threshold status * refine externally managed charge limits * perf(upower): target device refreshes * test(upower): build integration test dependency * fix: satisfy battery charge limit lint * ci: retrigger checks * style(upower): format merge resolution
1 parent f7b0a4a commit b38bf2d

11 files changed

Lines changed: 1653 additions & 34 deletions

assets/translations/en.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,22 @@
305305
},
306306
"power": {
307307
"battery": "Battery",
308+
"charging": {
309+
"configured": "Configured: {limits}",
310+
"display-only": "Display only",
311+
"error-denied": "Authorization was denied. Charge thresholds were not changed.",
312+
"error-failed": "Charge thresholds could not be changed.",
313+
"externally-managed": "Set by another tool. Configure UPower to manage charge thresholds.",
314+
"firmware-managed": "Charging is managed by system firmware",
315+
"full-charge": "Can charge to 100%",
316+
"starts": "Starts below {start}%",
317+
"starts-stops": "Starts below {start}% · Stops at {end}%",
318+
"stops": "Stops at {end}%",
319+
"title": "Battery charging",
320+
"unreadable": "Unavailable",
321+
"unsupported": "Charge thresholds are not supported",
322+
"use-thresholds": "Use charge thresholds"
323+
},
308324
"design-capacity": "Of design capacity",
309325
"health": "Battery Health",
310326
"performance-inhibited": "Performance unavailable",

meson.build

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,7 @@ _noctalia_sources = files(
485485
'src/dbus/system_bus.cpp',
486486
'src/dbus/tray/fcitx_status.cpp',
487487
'src/dbus/tray/tray_service.cpp',
488+
'src/dbus/upower/upower_charge_limit_support.cpp',
488489
'src/dbus/upower/upower_service.cpp',
489490
'src/debug/debug_service.cpp',
490491
'src/i18n/i18n.cpp',
@@ -1193,6 +1194,7 @@ if build_tests
11931194
'tray_identifier',
11941195
'triad_workspace_backend',
11951196
'ui_tree_reconciler',
1197+
'upower_charge_limit',
11961198
'upower_device_catalog',
11971199
'wayland_output_scale',
11981200
'wayland_toplevels_identity',
@@ -1217,6 +1219,23 @@ if build_tests
12171219
test(test_name, test_exe, args: test_args)
12181220
endforeach
12191221

1222+
upower_integration_exe = executable('upower_charge_limit_integration_test',
1223+
sources: files('tests/upower_charge_limit_integration_test.cpp'),
1224+
dependencies: noctalia_core_dep,
1225+
)
1226+
dbus_run_session = find_program('dbus-run-session', required: false)
1227+
if dbus_run_session.found()
1228+
test('upower_charge_limit_integration',
1229+
dbus_run_session,
1230+
args: [
1231+
'--', 'sh', '-c',
1232+
'export DBUS_SYSTEM_BUS_ADDRESS="$DBUS_SESSION_BUS_ADDRESS"; exec "$1"',
1233+
'sh', upower_integration_exe.full_path(),
1234+
],
1235+
depends: upower_integration_exe,
1236+
)
1237+
endif
1238+
12201239
config_validate_env = environment()
12211240
config_validate_env.set('NOCTALIA_CONFIG_HOME', meson.current_source_dir() / 'tests/config_validate/config-home')
12221241
config_validate_env.set('NOCTALIA_STATE_HOME', meson.current_source_dir() / 'tests/config_validate/state-home')

src/app/application_services.cpp

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1138,6 +1138,12 @@ void Application::initSystemBusServices() {
11381138
m_batteryHookState.reset(m_upowerService->state());
11391139
m_batteryWarningMonitor.evaluate(m_configService.config().battery, *m_upowerService, m_notificationManager);
11401140
m_upowerService->setChangeCallback([this, shouldRefreshControlCenter](const UPowerChange& change) {
1141+
if (change.origin != UPowerService::ChangeOrigin::DeviceState) {
1142+
if (shouldRefreshControlCenter()) {
1143+
m_panelManager.refresh();
1144+
}
1145+
return;
1146+
}
11411147
onUpowerStateChangedForHooks();
11421148
m_batteryWarningMonitor.evaluate(m_configService.config().battery, *m_upowerService, m_notificationManager);
11431149
if (m_bluetoothService != nullptr) {
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
#include "dbus/upower/upower_charge_limit_support.h"
2+
3+
#include <algorithm>
4+
#include <cctype>
5+
#include <charconv>
6+
#include <fstream>
7+
#include <ranges>
8+
#include <string>
9+
10+
namespace {
11+
12+
std::optional<std::uint32_t> readThresholdFile(const std::filesystem::path& path) {
13+
std::ifstream input(path);
14+
if (!input) {
15+
return std::nullopt;
16+
}
17+
18+
std::string value;
19+
std::getline(input, value);
20+
const auto first = value.find_first_not_of(" \t\r\n");
21+
if (first == std::string::npos) {
22+
return std::nullopt;
23+
}
24+
const auto last = value.find_last_not_of(" \t\r\n");
25+
const std::string_view trimmed(value.data() + first, last - first + 1);
26+
std::uint32_t threshold = 0;
27+
const auto [end, error] = std::from_chars(trimmed.data(), trimmed.data() + trimmed.size(), threshold);
28+
if (error != std::errc{} || end != trimmed.data() + trimmed.size() || threshold > 100U) {
29+
return std::nullopt;
30+
}
31+
return threshold;
32+
}
33+
34+
bool isPlainPowerSupplyComponent(std::string_view value) {
35+
if (value.empty() || value == "." || value == "..") {
36+
return false;
37+
}
38+
return std::ranges::all_of(value, [](unsigned char ch) {
39+
return std::isalnum(ch) != 0 || ch == '_' || ch == '-' || ch == '.' || ch == ':';
40+
});
41+
}
42+
43+
std::optional<std::string> powerSupplyComponent(std::string_view nativePath) {
44+
if (isPlainPowerSupplyComponent(nativePath)) {
45+
return std::string(nativePath);
46+
}
47+
48+
const std::filesystem::path path{nativePath};
49+
if (!path.is_absolute()
50+
|| !nativePath.starts_with("/sys/")
51+
|| std::ranges::any_of(path, [](const std::filesystem::path& part) { return part == ".." || part == "."; })) {
52+
return std::nullopt;
53+
}
54+
55+
const auto normalized = path.lexically_normal();
56+
const std::string component = normalized.filename().string();
57+
if (normalized.parent_path().filename() != "power_supply" || !isPlainPowerSupplyComponent(component)) {
58+
return std::nullopt;
59+
}
60+
return component;
61+
}
62+
63+
} // namespace
64+
65+
namespace upower::detail {
66+
67+
ChargeThresholdProbe
68+
readChargeThresholdsFromSysfs(std::string_view nativePath, const std::filesystem::path& powerSupplyRoot) {
69+
ChargeThresholdProbe result;
70+
const auto component = powerSupplyComponent(nativePath);
71+
if (!component.has_value()) {
72+
return result;
73+
}
74+
75+
// NativePath selects a kernel power_supply name only. Always read through the fixed class root,
76+
// even when UPower supplies its documented absolute /sys/devices/.../power_supply path.
77+
const auto batteryPath = powerSupplyRoot / *component;
78+
result.start = readThresholdFile(batteryPath / "charge_control_start_threshold");
79+
result.end = readThresholdFile(batteryPath / "charge_control_end_threshold");
80+
return result;
81+
}
82+
83+
} // namespace upower::detail
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
#pragma once
2+
3+
#include <cstdint>
4+
#include <filesystem>
5+
#include <optional>
6+
#include <string_view>
7+
8+
// Internal parsing surface shared with focused tests. This is separate from UPowerService's public
9+
// device model because callers should consume UPowerChargeLimitState, not probe sysfs themselves.
10+
namespace upower::detail {
11+
12+
struct ChargeThresholdProbe {
13+
std::optional<std::uint32_t> start;
14+
std::optional<std::uint32_t> end;
15+
16+
bool operator==(const ChargeThresholdProbe&) const = default;
17+
};
18+
19+
[[nodiscard]] ChargeThresholdProbe readChargeThresholdsFromSysfs(
20+
std::string_view nativePath, const std::filesystem::path& powerSupplyRoot = "/sys/class/power_supply"
21+
);
22+
} // namespace upower::detail

0 commit comments

Comments
 (0)