diff --git a/battery-threshold/99-battery-threshold.rules b/battery-threshold/99-battery-threshold.rules new file mode 100644 index 0000000..b23ba90 --- /dev/null +++ b/battery-threshold/99-battery-threshold.rules @@ -0,0 +1,7 @@ +# 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" + diff --git a/battery-threshold/README.md b/battery-threshold/README.md new file mode 100644 index 0000000..a8f0840 --- /dev/null +++ b/battery-threshold/README.md @@ -0,0 +1,80 @@ +# Battery Threshold + +![thumbnail](./thumbnail.webp) + +A plugin for Noctalia Shell to control the battery threshold on laptops, helping +extend battery lifespan. This plugin only works if your laptop supports charge +threshold control (as exported by the kernel in sysfs). The plugin looks like +this in action: + +## Features + +- **Bar Widget**: Shows current battery threshold in the bar +- **Panel**: Adjust battery threshold with a slider (40-100%) +- **Persistent Settings**: Saves and restores threshold across reboots + +## Usage + +Add the bar widget to your bar. Click to open the panel and adjust the battery +threshold using the slider. + +### Panel Controls + +- Drag the slider to set battery threshold (40-100%) +- Changes are applied immediately +- Settings persist across reboots + +## Setup (Required) + +This plugin requires write access to the battery threshold sysfs file. You can +configure permissions in one of three ways: + +### 1. Via UI (Recommended) + +If the plugin is loaded and read-only, open the panel and click the **Configure +Permissions** button. This will trigger a `pkexec` dialog prompting for +authentication to install the udev rules automatically. + +### 2. Via IPC Command + +You can trigger the automated setup from the terminal: + +```bash +noctalia msg plugin damian-ds7/battery-threshold setup +``` + +### 3. Manual Fallback + +If you do not have a Polkit agent running, you can manually run the included +script: + +```bash +sudo ./setup_rules.sh +``` + +**Note:** A logout/reboot is required for the new group membership +(`battery_ctl`) to take effect. + +## IPC Commands + +```bash +# Toggle panel +noctalia msg panel-toggle damian-ds7/battery-threshold:panel + +# Set threshold +noctalia msg plugin damian-ds7/battery-threshold:service all set +``` + +## Troubleshooting + +- **Read-only mode**: Ensure udev rule is installed, and you're in the correct + group +- **Not available**: Your laptop may not support charge threshold control, or + select the correct battery in the settings menu +- **Changes not saving**: Check write permissions on the sysfs file + +## Requirements + +- Laptop with battery charge threshold support (ThinkPad, ASUS, etc.) +- Tested on Asus Zenbook 14 UX3405 +- Tested on Asus TUF Gaming F15 FX506L 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..7407879 --- /dev/null +++ b/battery-threshold/plugin.toml @@ -0,0 +1,43 @@ +id = "damian-ds7/battery-threshold" +name = "Battery Threshold Control" +version = "1.0.0" +min_noctalia = "5.0.0" +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 = ["polkit"] + +[[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..dd1e0fe --- /dev/null +++ b/battery-threshold/service.luau @@ -0,0 +1,196 @@ +--!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 check_writable(path: string, callback: (boolean) -> ()) + noctalia.runAsync("test -w " .. path, function(res) + callback(res.exitCode == 0) + end) +end + +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) + noctalia.writeFile("threshold.txt", tostring(v)) + 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 + local saved_content = noctalia.readFile("threshold.txt") + if saved_content then + saved_val = tonumber(noctalia.string.trim(saved_content)) + 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", rules_script, 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 + +function update() + 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..3bb9446 --- /dev/null +++ b/battery-threshold/setup_rules.sh @@ -0,0 +1,55 @@ +#!/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 + +SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)" +RULE_FILE="$SCRIPT_DIR/99-battery-threshold.rules" + +# 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 [ ! -f "$RULE_FILE" ]; then + echo "Error: $RULE_FILE not found in current directory" >&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 "Installing $RULE_FILE" + +cp "$RULE_FILE" /etc/udev/rules.d/ + +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..ef58eea 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()