Feat/battery threshold (#7)

* feat(battery-threshold): add translations and rule file

* feat(battery-threshold): add setup script

* feat(battery-threshold): add plugin manifest

* feat(battery-threshold): implement battery-threshold service

* feat(battery-threshold): implement battery-threshold widget

* feat(battery-threshold): implement battery-threshold panel

* feat(battery-threshold): add readme

* chore(battery-threshold): add dependencies field

* chore(battery-threshold): add thumbnail

* docs(battery-threshold): add thumbnail to readme

* fix(battery-threshold): add thumbnail from generator

* chore(battery-threshold): add license and icon fields to plugin.toml

* style(battery-threshold): replace tabs with spaces

* style(battery-threshold): unflatten translation files

* refactor(battery-threshold): use translations for widget tooltip

* chore(battery-threshold): replace raw strings with translations

* chore(battery-threshold): remove panel ipc event

* chore(battery-threshold): check error on file read

* chore(battery-threshold): remove `setUpdateInterval` calls

* style(battery-threshold): fix formatting

* chore(battery-threshold): remove tags capital letters

* Update plugin.toml

* chore(battery-threshold): remove de fr and tr translations

* chore(battery-threshold): update thumbnail

* fix(battery-threshold): add shell quoting for runAsync

* chore(battery-threshold): embed rule file content in setup script

* chore(battery-threshold): update manifest

* refactor(battery-threshold): use noctalia.pluginDataDir, log read/write errors

* docs(battery-threshold): update README to match template

* chore(battery-threshold): add missing dependencies to manifest

---------

Co-authored-by: Lemmy <studio@quadbyte.net>
This commit is contained in:
Damian D'Souza
2026-07-28 09:30:26 -04:00
committed by GitHub
co-authored by Lemmy
parent aace2dafe2
commit 4b1714d2c5
8 changed files with 621 additions and 0 deletions
+63
View File
@@ -0,0 +1,63 @@
# Battery Threshold Control
Control the battery charge threshold on laptop batteries to help extend overall
battery lifespan. Someone would use this plugin to limit maximum charge levels
while plugged in, reducing battery wear and heat.
## Plugin
| Field | Value |
| ------- | ------------------------------------------------------------------- |
| ID | `damian-ds7/battery-threshold` |
| Entries | Bar widget: `battery-threshold`; panel: `panel`; service: `service` |
## Requirements
- `polkit` - required for interactive setup
- Laptop hardware supporting battery charge threshold control in sysfs
(`/sys/class/power_supply/*/charge_control_end_threshold`).
- The following external programs must be available on `PATH`: `test`, `pkexec`,
`bash`, `cat`, `getent`, `groupadd`, `usermod`, `udevadm`, `chgrp`, and
`chmod`.
## Usage
- **Bar Widget (`battery-threshold`)**: Displays the current battery threshold
in the bar. Click to toggle the panel.
- **Panel (`panel`)**: Adjust the battery threshold using a slider (40–100%).
Includes a **Configure Permissions** button if write access is missing. Toggle
the panel using:
```sh
noctalia msg panel-toggle damian-ds7/battery-threshold:panel
```
## Settings
| Setting | Type | Default | Description |
| ------------------ | -------- | ------------------------------ | ---------------------------------------------- |
| `battery_device` | `folder` | `/sys/class/power_supply/BAT0` | Path to the battery sysfs directory. |
| `charge_threshold` | `int` | `80` | Default charge threshold percentage (40–100%). |
## IPC
```sh
# Set charge threshold percentage (between 40 and 100)
noctalia msg plugin damian-ds7/battery-threshold:service all set 80
# Trigger setup script for udev permissions
noctalia msg plugin damian-ds7/battery-threshold:service all setup
```
## Notes
- **Permissions & Setup**: Requires write access to
`/sys/class/power_supply/BAT0/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
setup for `battery_ctl` group membership changes to take effect.
- **Manual Setup Fallback**: If Polkit is not available, run
`sudo ./setup_rules.sh` manually from the plugin directory.
- **Persistence**: Threshold settings are stored in `threshold.txt` in plugin
directory and restored across reboots.
+172
View File
@@ -0,0 +1,172 @@
--!nonstrict
local is_open = false
local function render_panel()
if not is_open then
return
end
local is_available = noctalia.state.get("is_available") == true
local is_writable = noctalia.state.get("is_writable") == true
local threshold = noctalia.state.get("current_threshold") or 0
local model_name = noctalia.state.get("battery_model_name") or ""
local title_text = noctalia.tr("panel.title")
local sub_text = ""
if not is_available then
sub_text = noctalia.tr("panel.not-available")
else
sub_text = model_name
end
local hint_text = ""
local hint_color = "on_surface_variant"
if not is_writable then
hint_text = noctalia.tr("panel.read-only")
hint_color = "error"
else
hint_text = noctalia.tr("panel.adjust-limit")
end
local children = {
ui.row({ align = "center", justify = "space_between" }, {
ui.column({ gap = 2 }, {
ui.label({
text = title_text,
fontSize = 16,
fontWeight = "bold",
color = "primary",
}),
ui.label({
text = sub_text,
fontSize = 12,
color = "on_surface_variant",
}),
}),
ui.button({
glyph = "close",
variant = "ghost",
onClick = "onCloseClicked",
}),
}),
}
if is_available then
table.insert(
children,
ui.separator({ thickness = 1, color = "outline/0.3", spacing = 8 })
)
if is_writable then
table.insert(
children,
ui.column({ gap = 8 }, {
ui.row({ align = "center", justify = "space_between" }, {
ui.label({
text = title_text,
fontSize = 14,
color = "on_surface",
}),
ui.label({
text = tostring(threshold) .. "%",
fontSize = 16,
fontWeight = "bold",
color = "primary",
}),
}),
ui.row({ align = "center", gap = 8 }, {
ui.label({
text = "40%",
fontSize = 11,
color = "on_surface_variant",
}),
ui.slider({
min = 40,
max = 100,
step = 5,
value = threshold,
enabled = is_writable,
onChange = "onSliderChange",
}),
ui.label({
text = "100%",
fontSize = 11,
color = "on_surface_variant",
}),
}),
ui.label({
text = hint_text,
fontSize = 11,
color = hint_color,
textAlign = "center",
}),
})
)
else
table.insert(
children,
ui.column({ gap = 12, align = "center" }, {
ui.label({
text = hint_text,
fontSize = 12,
color = "error",
textAlign = "center",
maxLines = 2,
}),
ui.button({
text = noctalia.tr("panel.button"),
glyph = "shield-lock",
variant = "primary",
onClick = "onRunSetup",
}),
})
)
end
end
panel.render(ui.column({ gap = 12, padding = 12 }, children))
end
function onOpen(context: string?)
is_open = true
render_panel()
end
function onClose()
is_open = false
end
function onSliderChange(value: string)
local num = tonumber(value)
if num then
noctalia.state.set("set_threshold_request", num)
noctalia.state.set("current_threshold", num)
render_panel()
end
end
function onCloseClicked()
panel.close()
end
function onRunSetup()
noctalia.state.set("run_setup_request", true)
end
noctalia.state.watch("current_threshold", function()
if is_open then
render_panel()
end
end)
noctalia.state.watch("is_writable", function()
if is_open then
render_panel()
end
end)
noctalia.state.watch("is_available", function()
if is_open then
render_panel()
end
end)
+43
View File
@@ -0,0 +1,43 @@
id = "damian-ds7/battery-threshold"
name = "Battery Threshold Control"
version = "1.0.0"
plugin_api = 3
author = "Damian D'Souza"
description = "Set the battery threshold for laptop batteries to extend battery lifespan"
license = "MIT"
icon = "battery-eco"
tags = ["hardware", "system", "utility", "bar", "panel"]
dependencies = ["test", "pkexec", "bash", "cat", "getent", "groupadd", "usermod", "udevadm", "chgrp", "chmod"]
[[service]]
id = "service"
entry = "service.luau"
[[widget]]
id = "battery-threshold"
entry = "widget.luau"
[[panel]]
id = "panel"
entry = "panel.luau"
width = 320
height = 220
placement = "floating"
position = "center"
open_near_click = true
[[setting]]
key = "battery_device"
type = "folder"
label_key = "settings.battery-device"
description_key = "settings.battery-device-desc"
default = "/sys/class/power_supply/BAT0"
[[setting]]
key = "charge_threshold"
type = "int"
label_key = "settings.charge-threshold"
description_key = "settings.charge-threshold-desc"
default = 80
min = 40
max = 100
+228
View File
@@ -0,0 +1,228 @@
--!nonstrict
local function get_batteries(): { string }
local batteries = {}
local files, _ = noctalia.listDir("/sys/class/power_supply")
if files then
for _, name in files do
local path = "/sys/class/power_supply/" .. name
if noctalia.fileExists(path .. "/charge_control_end_threshold") then
table.insert(batteries, path)
end
end
end
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 check_writable(path: string, callback: (boolean) -> ())
noctalia.runAsync("test -w " .. shell_quote(path), function(res)
callback(res.exitCode == 0)
end)
end
local data_dir, data_dir_err = noctalia.pluginDataDir()
if not data_dir or data_dir_err then
noctalia.log(
"Failed to get plugin data directory: "
.. tostring(data_dir_err or "unknown error")
)
end
local THRESHOLD_FILE_PATH: string? = if data_dir
then data_dir .. "/threshold.txt"
else nil
local function set_threshold(value: number)
local battery = get_active_battery()
if not battery then
return
end
local threshold_file = battery .. "/charge_control_end_threshold"
local v = math.floor(value + 0.5)
if v < 40 then
v = 40
end
if v > 100 then
v = 100
end
noctalia.log("Setting charge threshold to " .. tostring(v) .. "% on " .. battery)
local ok, err = noctalia.writeFile(threshold_file, tostring(v) .. "\n")
if ok then
noctalia.state.set("current_threshold", v)
if THRESHOLD_FILE_PATH then
local save_ok, save_err =
noctalia.writeFile(THRESHOLD_FILE_PATH, tostring(v))
if not save_ok then
noctalia.log(
"Failed to write saved threshold to "
.. THRESHOLD_FILE_PATH
.. ": "
.. tostring(save_err)
)
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
noctalia.state.set("is_available", false)
noctalia.state.set("is_writable", false)
noctalia.state.set("current_threshold", 0)
noctalia.state.set("battery_model_name", "")
return
end
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
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
local function run_setup()
local user = noctalia.getenv("USER")
if not user or user == "" then
noctalia.log("Could not get USER environment variable")
return
end
local plugin_dir = noctalia.pluginDir()
if not plugin_dir then
noctalia.log("Could not get plugin directory")
return
end
local rules_script = plugin_dir .. "/setup_rules.sh"
local cmd =
string.format("pkexec bash %s %s", shell_quote(rules_script), shell_quote(user))
noctalia.log("Running setup script via pkexec: " .. cmd)
noctalia.runAsync(cmd, function(result)
if result.exitCode == 0 then
noctalia.log("Setup completed successfully")
noctalia.notify(
noctalia.tr("notification.setup-title"),
noctalia.tr("notification.setup-success")
)
check_status()
else
noctalia.log("Setup failed with exit code " .. tostring(result.exitCode))
noctalia.notifyError(
noctalia.tr("notification.setup-title"),
noctalia.tr("notification.setup-fail")
)
end
noctalia.state.set("run_setup_request", false)
end)
end
noctalia.state.watch("run_setup_request", function(val)
if val == true then
run_setup()
end
end)
noctalia.state.watch("set_threshold_request", function(val)
if val then
local num = tonumber(val)
if num then
set_threshold(num)
end
end
end)
function onIpc(event, payload)
if event == "set" then
if payload then
local val = tonumber(payload)
if val and val >= 40 and val <= 100 then
set_threshold(val)
end
end
elseif event == "setup" then
run_setup()
end
end
function onConfigChanged()
check_status()
end
check_status()
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# ------------------------------
# Battery Threshold Udev Setup
# ------------------------------
# This script sets up udev rules to allow a non-root user to write to
# /sys/class/power_supply/BAT0/charge_control_end_threshold.
# It creates a group 'battery_ctl' and adds the target user to this group.
#
# Usage:
# $ sudo ./setup_rules.sh # uses SUDO_USER (with sudo)
# $ ./setup_rules.sh username # use provided username (if ran as root)
# ------------------------------
set -e
# Check if running as root
if [ "$EUID" -ne 0 ]; then
echo "Error: This script must be run as root (use sudo)"
exit 1
fi
# Determine target user
TARGET_USER=${SUDO_USER:-$1}
if [ -z "$TARGET_USER" ]; then
echo "Error: No target user specified." >&2
exit 1
fi
if ! getent group battery_ctl >/dev/null; then
echo "Creating battery_ctl group..."
groupadd battery_ctl
fi
echo "Adding $TARGET_USER to battery_ctl group..."
usermod -aG battery_ctl "$TARGET_USER"
echo "Writing udev rule to /etc/udev/rules.d/99-battery-threshold.rules..."
cat <<'EOF' >/etc/udev/rules.d/99-battery-threshold.rules
# Battery Threshold Control - udev rule
# Grants write access to charge_control_end_threshold for users in the
# 'battery_ctl' group.
SUBSYSTEM=="power_supply", KERNEL=="BAT*", \
RUN+="/bin/chgrp battery_ctl /sys$devpath/charge_control_end_threshold", \
RUN+="/bin/chmod g+w /sys$devpath/charge_control_end_threshold"
EOF
echo "Reloading rules..."
udevadm control --reload-rules && udevadm trigger
echo "You may need a reboot for the plugin's write access to take effect"
echo "Done!"
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

+26
View File
@@ -0,0 +1,26 @@
{
"settings": {
"battery-device": "Battery Device",
"battery-device-desc": "Battery to configure threshold for",
"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"
},
"notification": {
"error-title": "Battery Threshold Error",
"error-msg": "Failed to write battery threshold to {file}",
"setup-title": "Battery Threshold Setup",
"setup-fail": "Failed to configure permissions.",
"setup-success": "Permissions configured. Please log out and back in for group changes to take effect."
},
"actions": {
"widget-settings": "Widget Settings"
},
"panel": {
"title": "Battery Threshold",
"not-available": "Not available on this system",
"adjust-limit": "Drag slider to adjust limit",
"read-only": "Read-only: Install udev rule for write access",
"button": "Configure Permissions"
}
}
+35
View File
@@ -0,0 +1,35 @@
--!nonstrict
local function render_widget()
local is_available = noctalia.state.get("is_available") == true
local threshold = noctalia.state.get("current_threshold") or 0
local container = barWidget.isVertical() and ui.column or ui.row
if is_available then
barWidget.render(container({ gap = 4, align = "center" }, {
ui.glyph({ name = "charging-pile", size = 14 }),
ui.label({ text = tostring(threshold) .. "%", fontWeight = "bold" }),
}))
barWidget.setTooltip(
noctalia.tr("panel.title") .. " " .. tostring(threshold) .. "%"
)
else
barWidget.render(container({ gap = 4, align = "center" }, {
ui.glyph({ name = "charging-pile", size = 14, color = "on_surface/0.4" }),
}))
barWidget.setTooltip(noctalia.tr("settings.no-battery-device"))
end
end
noctalia.state.watch("current_threshold", render_widget)
noctalia.state.watch("is_available", render_widget)
function onClick()
noctalia.togglePanel("damian-ds7/battery-threshold:panel")
end
function update()
render_widget()
end
render_widget()