Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions battery-threshold/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ noctalia msg plugin damian-ds7/battery-threshold:service all setup
## Notes

- **Supported Devices**: Only works on laptops with battery charge threshold
support (ThinkPad, ASUS), tested on Asus Zenbook 14
support (ThinkPad, ASUS), tested on Asus Zenbook 14 and dual-battery ThinkPads
- **Permissions & Setup**: Requires write access to
`/sys/class/power_supply/BAT0/charge_control_end_threshold`. Automated setup
`/sys/class/power_supply/BAT*/charge_control_end_threshold`. Automated setup
creates the `battery_ctl` group, adds the active user to it, and installs
`99-battery-threshold.rules` to `/etc/udev/rules.d/`.
- **Relogin / Reboot**: A logout or system reboot is required after running
Expand Down
9 changes: 8 additions & 1 deletion battery-threshold/panel.luau
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ local function render_panel()

local children = {
ui.row({ align = "center", justify = "space_between" }, {
ui.column({ gap = 2 }, {
ui.column({ gap = 2, flexGrow = 1 }, {
ui.label({
text = title_text,
fontSize = 16,
Expand Down Expand Up @@ -86,6 +86,7 @@ local function render_panel()
step = 5,
value = threshold,
enabled = is_writable,
flexGrow = 1,
onChange = "onSliderChange",
}),
ui.label({
Expand Down Expand Up @@ -159,6 +160,12 @@ noctalia.state.watch("current_threshold", function()
end
end)

noctalia.state.watch("battery_model_name", function()
if is_open then
render_panel()
end
end)

noctalia.state.watch("is_writable", function()
if is_open then
render_panel()
Expand Down
4 changes: 2 additions & 2 deletions battery-threshold/plugin.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
id = "damian-ds7/battery-threshold"
name = "Battery Threshold Control"
version = "1.0.1"
version = "1.1.0"
plugin_api = 3
author = "Damian D'Souza"
description = "Set the battery threshold for laptop batteries to extend battery lifespan"
Expand Down Expand Up @@ -31,7 +31,7 @@ key = "battery_device"
type = "folder"
label_key = "settings.battery-device"
description_key = "settings.battery-device-desc"
default = "/sys/class/power_supply/BAT0"
default = ""

[[setting]]
key = "charge_threshold"
Expand Down
163 changes: 90 additions & 73 deletions battery-threshold/service.luau
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
--!nonstrict
local function get_batteries(): { string }
local configured = noctalia.getConfig("battery_device")
if
typeof(configured) == "string"
and configured ~= ""
and noctalia.fileExists(configured .. "/charge_control_end_threshold")
then
return { configured }
end

local batteries = {}
local files, _ = noctalia.listDir("/sys/class/power_supply")

Expand All @@ -12,28 +21,22 @@ local function get_batteries(): { string }
end
end

table.sort(batteries)
return batteries
end

local function get_active_battery(): string?
local configured = noctalia.getConfig("battery_device")

if
typeof(configured) == "string"
and configured ~= ""
and noctalia.fileExists(configured .. "/charge_control_end_threshold")
then
return configured
end

local list = get_batteries()
return list[1]
end

local function shell_quote(str: string): string
return "'" .. string.gsub(str, "'", "'\\''") .. "'"
end

local function read_trimmed(path: string): string?
local content, err = noctalia.readFile(path)
if content and not err then
return noctalia.string.trim(content)
end
return nil
end

local function check_writable(path: string, callback: (boolean) -> ())
noctalia.runAsync("test -w " .. shell_quote(path), function(res)
callback(res.exitCode == 0)
Expand All @@ -52,11 +55,10 @@ local THRESHOLD_FILE_PATH: string? = if data_dir
else nil

local function set_threshold(value: number)
local battery = get_active_battery()
if not battery then
local batteries = get_batteries()
if #batteries == 0 then
return
end
local threshold_file = battery .. "/charge_control_end_threshold"

local v = math.floor(value + 0.5)
if v < 40 then
Expand All @@ -66,10 +68,24 @@ local function set_threshold(value: number)
v = 100
end

noctalia.log("Setting charge threshold to " .. tostring(v) .. "% on " .. battery)
local any_ok = false
for _, battery in ipairs(batteries) do
local threshold_file = battery .. "/charge_control_end_threshold"
noctalia.log("Setting charge threshold to " .. tostring(v) .. "% on " .. battery)

local ok, err = noctalia.writeFile(threshold_file, tostring(v) .. "\n")
if ok then
any_ok = true
else
noctalia.log("Failed to write threshold to " .. threshold_file .. ": " .. tostring(err))
noctalia.notifyError(
noctalia.tr("notification.error-title"),
noctalia.tr("notification.error-msg", { file = threshold_file })
)
end
end

local ok, err = noctalia.writeFile(threshold_file, tostring(v) .. "\n")
if ok then
if any_ok then
noctalia.state.set("current_threshold", v)
if THRESHOLD_FILE_PATH then
local save_ok, save_err =
Expand All @@ -83,18 +99,12 @@ local function set_threshold(value: number)
)
end
end
else
noctalia.log("Failed to write threshold: " .. tostring(err))
noctalia.notifyError(
noctalia.tr("notification.error-title"),
noctalia.tr("notification.error-msg", { file = threshold_file })
)
end
end

local function check_status()
local battery = get_active_battery()
if not battery then
local batteries = get_batteries()
if #batteries == 0 then
noctalia.state.set("is_available", false)
noctalia.state.set("is_writable", false)
noctalia.state.set("current_threshold", 0)
Expand All @@ -104,55 +114,62 @@ local function check_status()

noctalia.state.set("is_available", true)

local model_name = ""
local content, err = noctalia.readFile(battery .. "/model_name")
if content and not err then
model_name = noctalia.string.trim(content)
end
noctalia.state.set("battery_model_name", model_name)

local threshold_file = battery .. "/charge_control_end_threshold"

local threshold_content = noctalia.readFile(threshold_file)
local current_val = 0
if threshold_content then
current_val = tonumber(noctalia.string.trim(threshold_content)) or 0
noctalia.state.set("current_threshold", current_val)
end

check_writable(threshold_file, function(writable)
noctalia.state.set("is_writable", writable)

if writable then
local saved_val = nil
if THRESHOLD_FILE_PATH and noctalia.fileExists(THRESHOLD_FILE_PATH) then
local saved_content, read_err = noctalia.readFile(THRESHOLD_FILE_PATH)
if saved_content and not read_err then
saved_val = tonumber(noctalia.string.trim(saved_content))
elseif read_err then
noctalia.log(
"Failed to read saved threshold from "
.. THRESHOLD_FILE_PATH
.. ": "
.. tostring(read_err)
)
end
end
local model_names = {}
for _, battery in ipairs(batteries) do
local model = read_trimmed(battery .. "/model_name")
local name = string.match(battery, "([^/]+)$") or battery
if model and model ~= "" then
table.insert(model_names, if #batteries > 1 then (name .. ": " .. model) else model)
else
table.insert(model_names, name)
end
end
noctalia.state.set("battery_model_name", table.concat(model_names, ", "))

if not saved_val then
local config_val = noctalia.getConfig("charge_threshold")
if typeof(config_val) == "number" then
saved_val = config_val
end
local first_battery = batteries[1]
local threshold_content = read_trimmed(first_battery .. "/charge_control_end_threshold")
local current_val = tonumber(threshold_content) or 0
noctalia.state.set("current_threshold", current_val)

local pending_checks = #batteries
local any_writable = false

for _, battery in ipairs(batteries) do
local threshold_file = battery .. "/charge_control_end_threshold"
check_writable(threshold_file, function(writable)
if writable then
any_writable = true
end

if saved_val and saved_val >= 40 and saved_val <= 100 then
if saved_val ~= current_val then
set_threshold(saved_val)
pending_checks -= 1
if pending_checks == 0 then
noctalia.state.set("is_writable", any_writable)

if any_writable then
local saved_val = nil
if THRESHOLD_FILE_PATH and noctalia.fileExists(THRESHOLD_FILE_PATH) then
local saved_content = read_trimmed(THRESHOLD_FILE_PATH)
if saved_content then
saved_val = tonumber(saved_content)
end
end

if not saved_val then
local config_val = noctalia.getConfig("charge_threshold")
if typeof(config_val) == "number" then
saved_val = config_val
end
end

if saved_val and saved_val >= 40 and saved_val <= 100 then
if saved_val ~= current_val then
set_threshold(saved_val)
end
end
end
end
end
end)
end)
end
end

local function run_setup()
Expand Down
2 changes: 1 addition & 1 deletion battery-threshold/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
},
"settings": {
"battery-device": "Battery Device",
"battery-device-desc": "Battery to configure threshold for",
"battery-device-desc": "Battery to configure threshold for (leave empty for all detected batteries)",
"charge-threshold": "Charge Threshold",
"charge-threshold-desc": "The percentage at which the battery should stop charging",
"no-battery-device": "No configurable batteries are available on this system"
Expand Down