diff --git a/battery-threshold/README.md b/battery-threshold/README.md new file mode 100644 index 0000000..40fa7c2 --- /dev/null +++ b/battery-threshold/README.md @@ -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. diff --git a/battery-threshold/panel.luau b/battery-threshold/panel.luau new file mode 100644 index 0000000..673a4b9 --- /dev/null +++ b/battery-threshold/panel.luau @@ -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) diff --git a/battery-threshold/plugin.toml b/battery-threshold/plugin.toml new file mode 100644 index 0000000..65bc0be --- /dev/null +++ b/battery-threshold/plugin.toml @@ -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 diff --git a/battery-threshold/service.luau b/battery-threshold/service.luau new file mode 100644 index 0000000..a9a956f --- /dev/null +++ b/battery-threshold/service.luau @@ -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() diff --git a/battery-threshold/setup_rules.sh b/battery-threshold/setup_rules.sh new file mode 100755 index 0000000..1865d41 --- /dev/null +++ b/battery-threshold/setup_rules.sh @@ -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!" diff --git a/battery-threshold/thumbnail.webp b/battery-threshold/thumbnail.webp new file mode 100644 index 0000000..d71bc61 Binary files /dev/null and b/battery-threshold/thumbnail.webp differ diff --git a/battery-threshold/translations/en.json b/battery-threshold/translations/en.json new file mode 100644 index 0000000..28794c5 --- /dev/null +++ b/battery-threshold/translations/en.json @@ -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" + } +} diff --git a/battery-threshold/widget.luau b/battery-threshold/widget.luau new file mode 100644 index 0000000..f357050 --- /dev/null +++ b/battery-threshold/widget.luau @@ -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()