From c96e8b159f7e3b780c5090c912a9796ef2d64d57 Mon Sep 17 00:00:00 2001 From: Biplob Sutradhar Date: Mon, 17 Aug 2026 14:14:24 +0600 Subject: [PATCH 1/2] add support for multiple batteries with individual threshold controls and status tooltips --- battery-threshold/README.md | 42 +-- battery-threshold/panel.luau | 289 +++++++++++++---- battery-threshold/plugin.toml | 8 +- battery-threshold/service.luau | 414 ++++++++++++++++++++----- battery-threshold/setup_rules.sh | 12 +- battery-threshold/translations/en.json | 5 +- battery-threshold/widget.luau | 67 +++- 7 files changed, 667 insertions(+), 170 deletions(-) diff --git a/battery-threshold/README.md b/battery-threshold/README.md index c8e0b94c..ab22dc1d 100644 --- a/battery-threshold/README.md +++ b/battery-threshold/README.md @@ -2,7 +2,8 @@ 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. +while plugged in, reducing battery wear and heat. Supports laptops with single or +multiple batteries (e.g. ThinkPads with internal and removable batteries). ## Plugin @@ -21,10 +22,11 @@ while plugged in, reducing battery wear and heat. ## 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: + limit in the bar. For multiple batteries, displays the limits or combined status, with + a rich hover tooltip showing each battery's health and threshold. Click to toggle the panel. +- **Panel (`panel`)**: Adjust the battery threshold using dedicated sliders (40–100%) + for each detected battery. Includes quick sync buttons and a **Configure Permissions** + button if write access is missing. Toggle the panel using: ```sh noctalia msg panel-toggle damian-ds7/battery-threshold:panel @@ -32,32 +34,34 @@ 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%). | +| Setting | Type | Default | Description | +| ------------------ | -------- | ------- | ---------------------------------------------------------------------------------------------- | +| `battery_device` | `folder` | `""` | Path to a specific battery sysfs directory (leave empty to auto-detect all batteries). | +| `charge_threshold` | `int` | `80` | Default charge threshold percentage (40–100%). | ## IPC ```sh -# Set charge threshold percentage (between 40 and 100) +# Set charge threshold percentage on all batteries (between 40 and 100) noctalia msg plugin damian-ds7/battery-threshold:service all set 80 +# Set charge threshold percentage on a specific battery (e.g. BAT0 or BAT1) +noctalia msg plugin damian-ds7/battery-threshold:service all set BAT1 80 + # Trigger setup script for udev permissions 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 +- **Supported Devices**: Works on laptops with battery charge threshold + support (ThinkPad, ASUS, etc.), including dual-battery laptops (e.g., ThinkPad T480/T470/T460 with BAT0 & BAT1). - **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 - 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 - data directory and restored across reboots. +- **Relogin / Reboot**: A logout or system reboot is recommended after running + setup for new `battery_ctl` group membership changes to take effect in desktop sessions. +- **Manual Setup Fallback**: Run `sudo ./setup_rules.sh` manually from the plugin directory. +- **Persistence**: Threshold settings are stored in `thresholds.json` (and `threshold.txt`) + in the plugin data directory and restored across reboots. diff --git a/battery-threshold/panel.luau b/battery-threshold/panel.luau index 673a4b95..18a59cf7 100644 --- a/battery-threshold/panel.luau +++ b/battery-threshold/panel.luau @@ -8,6 +8,7 @@ local function render_panel() local is_available = noctalia.state.get("is_available") == true local is_writable = noctalia.state.get("is_writable") == true + local batteries = noctalia.state.get("batteries") or {} local threshold = noctalia.state.get("current_threshold") or 0 local model_name = noctalia.state.get("battery_model_name") or "" @@ -15,37 +16,35 @@ local function render_panel() local sub_text = "" if not is_available then sub_text = noctalia.tr("panel.not-available") + elseif #batteries > 1 then + sub_text = noctalia.tr("panel.multiple-batteries", { count = #batteries }) 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.row({ align = "center", gap = 8 }, { + ui.glyph({ name = "battery-eco", size = 20, color = "primary" }), + ui.column({ gap = 2 }, { + ui.label({ + text = title_text, + fontSize = 15, + fontWeight = "bold", + color = "primary", + }), + ui.label({ + text = sub_text, + fontSize = 11, + color = "on_surface_variant", + maxLines = 1, + }), }), }), ui.button({ glyph = "close", variant = "ghost", + controlSize = "sm", onClick = "onCloseClicked", }), }), @@ -54,77 +53,243 @@ local function render_panel() if is_available then table.insert( children, - ui.separator({ thickness = 1, color = "outline/0.3", spacing = 8 }) + ui.separator({ thickness = 1, color = "outline/0.3", spacing = 4 }) ) - if is_writable then + -- If any battery is not writable, show permission setup banner + local has_unwritable = not is_writable + if #batteries > 0 then + for _, b in ipairs(batteries) do + if b.is_writable == false then + has_unwritable = true + break + end + end + end + + if has_unwritable then table.insert( children, - ui.column({ gap = 8 }, { + ui.column({ + gap = 6, + padding = 8, + radius = 8, + fill = "error/0.1", + border = "error/0.3", + borderWidth = 1, + align = "center", + }, { + ui.label({ + text = noctalia.tr("panel.read-only"), + fontSize = 11, + color = "error", + textAlign = "center", + maxLines = 2, + }), + ui.button({ + text = noctalia.tr("panel.button"), + glyph = "shield-lock", + variant = "primary", + controlSize = "sm", + onClick = "onRunSetup", + }), + }) + ) + end + + if #batteries > 1 then + -- Multi-battery display in scrollable list + local battery_cards = {} + + for _, bat in ipairs(batteries) do + local bat_name = bat.name + local bat_model = bat.model_name ~= "" and bat.model_name or "" + local bat_status = bat.status ~= "" and bat.status or "" + local bat_cap = bat.capacity or 0 + local bat_thresh = bat.current_threshold or 80 + local bat_writable = bat.is_writable == true + + local status_desc = tostring(bat_cap) .. "%" + if bat_status ~= "" and bat_status ~= "Unknown" then + status_desc = status_desc .. " · " .. bat_status + end + + local card = ui.column({ + gap = 5, + padding = 8, + radius = 8, + fill = "surface_variant/0.35", + border = "outline/0.25", + borderWidth = 1, + align = "stretch", + }, { ui.row({ align = "center", justify = "space_between" }, { - ui.label({ - text = title_text, - fontSize = 14, - color = "on_surface", + ui.row({ align = "center", gap = 6 }, { + ui.label({ + text = bat_name, + fontSize = 13, + fontWeight = "bold", + color = "primary", + }), + ui.label({ + text = bat_model ~= "" and ("(" .. bat_model .. ")") or "", + fontSize = 11, + color = "on_surface_variant", + maxLines = 1, + }), }), ui.label({ - text = tostring(threshold) .. "%", - fontSize = 16, + text = tostring(bat_thresh) .. "%", + fontSize = 14, fontWeight = "bold", color = "primary", }), }), + ui.row({ align = "center", justify = "space_between" }, { + ui.label({ + text = status_desc, + fontSize = 10, + color = "on_surface_variant", + }), + ui.label({ + text = if bat_writable + then noctalia.tr("panel.adjust-limit") + else noctalia.tr("panel.read-only"), + fontSize = 10, + color = if bat_writable then "on_surface_variant" else "error", + }), + }), ui.row({ align = "center", gap = 8 }, { ui.label({ text = "40%", - fontSize = 11, + fontSize = 10, color = "on_surface_variant", }), ui.slider({ min = 40, max = 100, step = 5, - value = threshold, - enabled = is_writable, - onChange = "onSliderChange", + value = bat_thresh, + enabled = bat_writable, + flexGrow = 1, + onChange = function(val) + local num = tonumber(val) + if num then + bat.current_threshold = num + noctalia.state.set("set_battery_threshold_request", { + name = bat_name, + value = num, + }) + render_panel() + end + end, }), ui.label({ text = "100%", - fontSize = 11, + fontSize = 10, color = "on_surface_variant", }), }), - ui.label({ - text = hint_text, - fontSize = 11, - color = hint_color, - textAlign = "center", - }), + }) + + table.insert(battery_cards, card) + end + + table.insert( + children, + ui.scroll({ + flexGrow = 1, + gap = 8, + align = "stretch", + }, { + ui.column({ gap = 8, flexGrow = 1, align = "stretch" }, battery_cards), }) ) - else + + -- Quick action button to sync all batteries table.insert( children, - ui.column({ gap = 12, align = "center" }, { - ui.label({ - text = hint_text, - fontSize = 12, - color = "error", - textAlign = "center", - maxLines = 2, + ui.row({ justify = "space_between", align = "center" }, { + ui.button({ + text = noctalia.tr("panel.sync-all"), + glyph = "refresh", + variant = "outline", + controlSize = "sm", + onClick = "onSyncAll80", }), ui.button({ - text = noctalia.tr("panel.button"), - glyph = "shield-lock", - variant = "primary", - onClick = "onRunSetup", + text = "100% (" .. noctalia.tr("panel.max") .. ")", + glyph = "bolt", + variant = "ghost", + controlSize = "sm", + onClick = "onSyncAll100", }), }) ) + else + -- Single battery display + local single_bat = batteries[1] + local cur_thresh = if single_bat + then single_bat.current_threshold + else threshold + local writable = if single_bat then single_bat.is_writable else is_writable + + if writable then + table.insert( + children, + ui.column({ gap = 8, flexGrow = 1, align = "stretch" }, { + ui.row({ align = "center", justify = "space_between" }, { + ui.label({ + text = title_text, + fontSize = 14, + color = "on_surface", + }), + ui.label({ + text = tostring(cur_thresh) .. "%", + 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 = cur_thresh, + enabled = writable, + flexGrow = 1, + onChange = "onSliderChange", + }), + ui.label({ + text = "100%", + fontSize = 11, + color = "on_surface_variant", + }), + }), + ui.label({ + text = noctalia.tr("panel.adjust-limit"), + fontSize = 11, + color = "on_surface_variant", + textAlign = "center", + }), + }) + ) + end end end - panel.render(ui.column({ gap = 12, padding = 12 }, children)) + panel.render(ui.column({ + flexGrow = 1, + gap = 8, + padding = 12, + align = "stretch", + }, children)) end function onOpen(context: string?) @@ -145,6 +310,18 @@ function onSliderChange(value: string) end end +function onSyncAll80() + noctalia.state.set("set_threshold_request", 80) + noctalia.state.set("current_threshold", 80) + render_panel() +end + +function onSyncAll100() + noctalia.state.set("set_threshold_request", 100) + noctalia.state.set("current_threshold", 100) + render_panel() +end + function onCloseClicked() panel.close() end @@ -153,6 +330,12 @@ function onRunSetup() noctalia.state.set("run_setup_request", true) end +noctalia.state.watch("batteries", function() + if is_open then + render_panel() + end +end) + noctalia.state.watch("current_threshold", function() if is_open then render_panel() diff --git a/battery-threshold/plugin.toml b/battery-threshold/plugin.toml index 004dbcbc..56cb8c41 100644 --- a/battery-threshold/plugin.toml +++ b/battery-threshold/plugin.toml @@ -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" @@ -20,8 +20,8 @@ entry = "widget.luau" [[panel]] id = "panel" entry = "panel.luau" -width = 320 -height = 220 +width = 340 +height = 340 placement = "floating" position = "center" open_near_click = true @@ -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" diff --git a/battery-threshold/service.luau b/battery-threshold/service.luau index ceba96a8..20a53154 100644 --- a/battery-threshold/service.luau +++ b/battery-threshold/service.luau @@ -1,5 +1,18 @@ --!nonstrict -local function get_batteries(): { string } +export type BatteryInfo = { + name: string, + path: string, + model_name: string, + manufacturer: string, + capacity: number, + status: string, + is_writable: boolean, + current_threshold: number, +} + +local current_batteries: { BatteryInfo } = {} + +local function get_battery_paths(): { string } local batteries = {} local files, _ = noctalia.listDir("/sys/class/power_supply") @@ -12,24 +25,10 @@ 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 @@ -47,17 +46,76 @@ if not data_dir or data_dir_err then .. 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 +local THRESHOLDS_JSON_PATH: string? = if data_dir + then data_dir .. "/thresholds.json" + else nil + +local function load_saved_thresholds(): { [string]: number } + local saved: { [string]: number } = {} + if THRESHOLDS_JSON_PATH and noctalia.fileExists(THRESHOLDS_JSON_PATH) then + local content, err = noctalia.readFile(THRESHOLDS_JSON_PATH) + if content and not err then + local decoded = noctalia.json.decode(content) + if typeof(decoded) == "table" then + for k, v in pairs(decoded) do + local num = tonumber(v) + if num and num >= 40 and num <= 100 then + saved[tostring(k)] = num + end + end + end + end + end + + -- Fallback to legacy threshold.txt + if THRESHOLD_FILE_PATH and noctalia.fileExists(THRESHOLD_FILE_PATH) then + local content, err = noctalia.readFile(THRESHOLD_FILE_PATH) + if content and not err then + local num = tonumber(noctalia.string.trim(content)) + if num and num >= 40 and num <= 100 then + saved["_legacy"] = num + end + end + end + + return saved +end + +local function save_thresholds(saved_map: { [string]: number }) + if THRESHOLDS_JSON_PATH then + local json_str = noctalia.json.encode(saved_map, true) + if json_str then + local ok, err = noctalia.writeFile(THRESHOLDS_JSON_PATH, json_str) + if not ok then + noctalia.log("Failed to save thresholds to JSON: " .. tostring(err)) + end + end end - local threshold_file = battery .. "/charge_control_end_threshold" + -- Also save first threshold to threshold.txt for backwards compatibility + if THRESHOLD_FILE_PATH then + local first_val = nil + for _, b in ipairs(current_batteries) do + if saved_map[b.name] then + first_val = saved_map[b.name] + break + end + end + if not first_val then + first_val = saved_map["_legacy"] + end + if first_val then + noctalia.writeFile(THRESHOLD_FILE_PATH, tostring(first_val)) + end + end +end + +local function set_battery_threshold(battery_id_or_path: string, value: number) local v = math.floor(value + 0.5) if v < 40 then v = 40 @@ -66,25 +124,71 @@ local function set_threshold(value: number) v = 100 end - noctalia.log("Setting charge threshold to " .. tostring(v) .. "% on " .. battery) + local target: BatteryInfo? = nil + for _, b in ipairs(current_batteries) do + if b.name == battery_id_or_path or b.path == battery_id_or_path then + target = b + break + end + end + + if not target then + local path = battery_id_or_path + if not string.find(path, "/") then + path = "/sys/class/power_supply/" .. battery_id_or_path + end + if noctalia.fileExists(path .. "/charge_control_end_threshold") then + local name = string.match(path, "([^/]+)$") or battery_id_or_path + target = { + name = name, + path = path, + model_name = "", + manufacturer = "", + capacity = 0, + status = "", + is_writable = true, + current_threshold = 0, + } + table.insert(current_batteries, target) + end + end + + if not target then + noctalia.log("Battery not found: " .. tostring(battery_id_or_path)) + return + end + + local threshold_file = target.path .. "/charge_control_end_threshold" + noctalia.log( + "Setting charge threshold to " + .. tostring(v) + .. "% on " + .. target.name + .. " (" + .. target.path + .. ")" + ) 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 + target.current_threshold = v + local saved_map = load_saved_thresholds() + saved_map[target.name] = v + saved_map["_legacy"] = v + save_thresholds(saved_map) + + noctalia.state.set("batteries", current_batteries) + noctalia.state.set( + "current_threshold", + current_batteries[1] and current_batteries[1].current_threshold or v + ) else - noctalia.log("Failed to write threshold: " .. tostring(err)) + noctalia.log( + "Failed to write threshold on " + .. target.name + .. ": " + .. tostring(err) + ) noctalia.notifyError( noctalia.tr("notification.error-title"), noctalia.tr("notification.error-msg", { file = threshold_file }) @@ -92,9 +196,49 @@ local function set_threshold(value: number) end end +local function set_all_thresholds(value: number) + local v = math.floor(value + 0.5) + if v < 40 then + v = 40 + end + if v > 100 then + v = 100 + end + + if #current_batteries == 0 then + local paths = get_battery_paths() + for _, p in ipairs(paths) do + set_battery_threshold(p, v) + end + return + end + + for _, b in ipairs(current_batteries) do + set_battery_threshold(b.name, v) + end +end + +local function set_threshold(value: number) + set_all_thresholds(value) +end + local function check_status() - local battery = get_active_battery() - if not battery then + local paths = get_battery_paths() + + -- Check if user configured a specific battery device + local configured = noctalia.getConfig("battery_device") + if + typeof(configured) == "string" + and configured ~= "" + and configured ~= "all" + and noctalia.fileExists(configured .. "/charge_control_end_threshold") + then + paths = { configured } + end + + if #paths == 0 then + current_batteries = {} + noctalia.state.set("batteries", {}) noctalia.state.set("is_available", false) noctalia.state.set("is_writable", false) noctalia.state.set("current_threshold", 0) @@ -102,57 +246,122 @@ local function check_status() return end - noctalia.state.set("is_available", true) + local saved_map = load_saved_thresholds() + local config_val = noctalia.getConfig("charge_threshold") + local default_threshold = if typeof(config_val) == "number" + and config_val >= 40 + and config_val <= 100 + then config_val + else 80 - 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 new_batteries = {} + local pending_checks = #paths + local any_writable = false + local all_writable = true - local threshold_file = battery .. "/charge_control_end_threshold" + for i, path in ipairs(paths) do + local name = string.match(path, "([^/]+)$") or ("BAT" .. tostring(i - 1)) - 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 + local model_name = "" + local model_content, _ = noctalia.readFile(path .. "/model_name") + if model_content then + model_name = noctalia.string.trim(model_content) + end - check_writable(threshold_file, function(writable) - noctalia.state.set("is_writable", writable) + local manufacturer = "" + local mfg_content, _ = noctalia.readFile(path .. "/manufacturer") + if mfg_content then + manufacturer = noctalia.string.trim(mfg_content) + end - 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 capacity = 0 + local cap_content, _ = noctalia.readFile(path .. "/capacity") + if cap_content then + capacity = tonumber(noctalia.string.trim(cap_content)) or 0 + end + + local status = "Unknown" + local status_content, _ = noctalia.readFile(path .. "/status") + if status_content then + status = noctalia.string.trim(status_content) + end + + local threshold_file = path .. "/charge_control_end_threshold" + local current_val = 0 + local threshold_content = noctalia.readFile(threshold_file) + if threshold_content then + current_val = tonumber(noctalia.string.trim(threshold_content)) or 0 + end - if not saved_val then - local config_val = noctalia.getConfig("charge_threshold") - if typeof(config_val) == "number" then - saved_val = config_val + local bat_info: BatteryInfo = { + name = name, + path = path, + model_name = model_name, + manufacturer = manufacturer, + capacity = capacity, + status = status, + is_writable = false, + current_threshold = current_val, + } + table.insert(new_batteries, bat_info) + + check_writable(threshold_file, function(writable) + bat_info.is_writable = writable + if writable then + any_writable = true + local target_threshold = saved_map[name] + or saved_map["_legacy"] + or default_threshold + if + target_threshold + and target_threshold >= 40 + and target_threshold <= 100 + then + if current_val ~= target_threshold then + local ok, _ = noctalia.writeFile( + threshold_file, + tostring(target_threshold) .. "\n" + ) + if ok then + bat_info.current_threshold = target_threshold + end + end end + else + all_writable = false 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 + current_batteries = new_batteries + noctalia.state.set("batteries", current_batteries) + noctalia.state.set("is_available", true) + noctalia.state.set("is_writable", any_writable) + noctalia.state.set( + "current_threshold", + current_batteries[1] + and current_batteries[1].current_threshold + or 0 + ) + + local model_summary = "" + if #current_batteries == 1 then + model_summary = current_batteries[1].model_name + elseif #current_batteries > 1 then + local models = {} + for _, b in ipairs(current_batteries) do + local label = b.name + if b.model_name ~= "" then + label = label .. ": " .. b.model_name + end + table.insert(models, label) + end + model_summary = table.concat(models, ", ") end + noctalia.state.set("battery_model_name", model_summary) end - end - end) + end) + end end local function run_setup() @@ -197,7 +406,28 @@ noctalia.state.watch("set_threshold_request", function(val) if val then local num = tonumber(val) if num then - set_threshold(num) + set_all_thresholds(num) + end + end +end) + +noctalia.state.watch("set_battery_threshold_request", function(val) + if typeof(val) == "table" then + local bat = val.battery or val.name or val.path + local num = tonumber(val.value or val.threshold) + if bat and num then + set_battery_threshold(tostring(bat), num) + end + elseif typeof(val) == "string" then + local bat, num_str = string.match(val, "^([^:]+):(%d+)$") + if not bat then + bat, num_str = string.match(val, "^(%S+)%s+(%d+)$") + end + if bat and num_str then + local num = tonumber(num_str) + if num then + set_battery_threshold(bat, num) + end end end end) @@ -205,9 +435,21 @@ 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) + local payload_str = tostring(payload) + local bat, val_str = string.match(payload_str, "^(%S+)%s+(%d+)$") + if not bat then + bat, val_str = string.match(payload_str, "^([^:]+):(%d+)$") + end + if bat and val_str then + local num = tonumber(val_str) + if num and num >= 40 and num <= 100 then + set_battery_threshold(bat, num) + end + else + local val = tonumber(payload) + if val and val >= 40 and val <= 100 then + set_all_thresholds(val) + end end end elseif event == "setup" then @@ -219,4 +461,12 @@ function onConfigChanged() check_status() end +function update() + check_status() +end + +if typeof(service) == "table" and typeof(service.setUpdateInterval) == "function" then + service.setUpdateInterval(5000) +end + check_status() diff --git a/battery-threshold/setup_rules.sh b/battery-threshold/setup_rules.sh index aaed2f6e..e81b4153 100755 --- a/battery-threshold/setup_rules.sh +++ b/battery-threshold/setup_rules.sh @@ -4,7 +4,7 @@ # 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. +# /sys/class/power_supply/BAT*/charge_control_end_threshold. # It creates a group 'battery_ctl' and adds the target user to this group. # # Usage: @@ -88,12 +88,20 @@ SUBSYSTEM=="power_supply", KERNEL=="BAT*", \ RUN+="/bin/chmod g+w /sys$devpath/charge_control_end_threshold" EOF +echo "Applying permissions to existing batteries..." +for threshold_file in /sys/class/power_supply/BAT*/charge_control_end_threshold; do + if [ -f "$threshold_file" ]; then + chgrp battery_ctl "$threshold_file" 2>/dev/null || true + chmod g+w "$threshold_file" 2>/dev/null || true + fi +done + echo "Reloading rules..." udevadm control --reload-rules && udevadm trigger echo "" -echo "You may need a reboot for the plugin's write access to take effect." +echo "Permissions applied! You may need to log out and log back in for new group membership to take full effect in active desktop sessions." echo "Done!" echo "" read -rp "Press Enter to exit..." diff --git a/battery-threshold/translations/en.json b/battery-threshold/translations/en.json index ea0f5981..0bf06503 100644 --- a/battery-threshold/translations/en.json +++ b/battery-threshold/translations/en.json @@ -12,13 +12,16 @@ "panel": { "adjust-limit": "Drag slider to adjust limit", "button": "Configure Permissions", + "max": "Max", + "multiple-batteries": "{count} Batteries Detected", "not-available": "Not available on this system", "read-only": "Read-only: Install udev rule for write access", + "sync-all": "Set all to 80%", "title": "Battery Threshold" }, "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" diff --git a/battery-threshold/widget.luau b/battery-threshold/widget.luau index f3570509..3d071821 100644 --- a/battery-threshold/widget.luau +++ b/battery-threshold/widget.luau @@ -1,26 +1,75 @@ --!nonstrict local function render_widget() local is_available = noctalia.state.get("is_available") == true + local batteries = noctalia.state.get("batteries") or {} 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 + if not is_available then 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")) + return end + + local label_text = "" + local tooltip_lines = {} + + if #batteries > 1 then + local all_same = true + local first_thresh = batteries[1] and batteries[1].current_threshold or 80 + local thresh_parts = {} + + table.insert(tooltip_lines, noctalia.tr("panel.title") .. ":") + + for _, bat in ipairs(batteries) do + local b_thresh = bat.current_threshold or 80 + if b_thresh ~= first_thresh then + all_same = false + end + table.insert(thresh_parts, tostring(b_thresh) .. "%") + + local bat_desc = "• " .. bat.name + if bat.model_name ~= "" then + bat_desc = bat_desc .. " (" .. bat.model_name .. ")" + end + bat_desc = bat_desc .. ": " .. tostring(b_thresh) .. "%" + if bat.capacity then + bat_desc = bat_desc .. " [" .. tostring(bat.capacity) .. "%" + if bat.status and bat.status ~= "" and bat.status ~= "Unknown" then + bat_desc = bat_desc .. " · " .. bat.status + end + bat_desc = bat_desc .. "]" + end + table.insert(tooltip_lines, bat_desc) + end + + if all_same then + label_text = tostring(first_thresh) .. "%" + else + label_text = table.concat(thresh_parts, " / ") + end + else + label_text = tostring(threshold) .. "%" + local single_bat = batteries[1] + local tip = noctalia.tr("panel.title") .. " " .. tostring(threshold) .. "%" + if single_bat and single_bat.model_name ~= "" then + tip = tip .. " (" .. single_bat.model_name .. ")" + end + table.insert(tooltip_lines, tip) + end + + barWidget.render(container({ gap = 4, align = "center" }, { + ui.glyph({ name = "charging-pile", size = 14, color = "primary" }), + ui.label({ text = label_text, fontWeight = "bold" }), + })) + + barWidget.setTooltip(table.concat(tooltip_lines, "\n")) end +noctalia.state.watch("batteries", render_widget) noctalia.state.watch("current_threshold", render_widget) noctalia.state.watch("is_available", render_widget) From 6942b44936d651f5634c103a654544dceb1e04de Mon Sep 17 00:00:00 2001 From: Biplob Sutradhar Date: Tue, 18 Aug 2026 20:49:30 +0600 Subject: [PATCH 2/2] refactor: removing complex state management and consolidating threshold logic --- battery-threshold/README.md | 40 ++- battery-threshold/panel.luau | 286 ++++------------- battery-threshold/plugin.toml | 4 +- battery-threshold/service.luau | 423 ++++++------------------- battery-threshold/setup_rules.sh | 12 +- battery-threshold/translations/en.json | 3 - battery-threshold/widget.luau | 67 +--- 7 files changed, 181 insertions(+), 654 deletions(-) diff --git a/battery-threshold/README.md b/battery-threshold/README.md index ab22dc1d..e966f7ba 100644 --- a/battery-threshold/README.md +++ b/battery-threshold/README.md @@ -2,8 +2,7 @@ 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. Supports laptops with single or -multiple batteries (e.g. ThinkPads with internal and removable batteries). +while plugged in, reducing battery wear and heat. ## Plugin @@ -22,11 +21,10 @@ multiple batteries (e.g. ThinkPads with internal and removable batteries). ## Usage - **Bar Widget (`battery-threshold`)**: Displays the current battery threshold - limit in the bar. For multiple batteries, displays the limits or combined status, with - a rich hover tooltip showing each battery's health and threshold. Click to toggle the panel. -- **Panel (`panel`)**: Adjust the battery threshold using dedicated sliders (40–100%) - for each detected battery. Includes quick sync buttons and a **Configure Permissions** - button if write access is missing. Toggle the panel using: + 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 @@ -34,34 +32,32 @@ noctalia msg panel-toggle damian-ds7/battery-threshold:panel ## Settings -| Setting | Type | Default | Description | -| ------------------ | -------- | ------- | ---------------------------------------------------------------------------------------------- | -| `battery_device` | `folder` | `""` | Path to a specific battery sysfs directory (leave empty to auto-detect all batteries). | -| `charge_threshold` | `int` | `80` | Default charge threshold percentage (40–100%). | +| 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 on all batteries (between 40 and 100) +# Set charge threshold percentage (between 40 and 100) noctalia msg plugin damian-ds7/battery-threshold:service all set 80 -# Set charge threshold percentage on a specific battery (e.g. BAT0 or BAT1) -noctalia msg plugin damian-ds7/battery-threshold:service all set BAT1 80 - # Trigger setup script for udev permissions noctalia msg plugin damian-ds7/battery-threshold:service all setup ``` ## Notes -- **Supported Devices**: Works on laptops with battery charge threshold - support (ThinkPad, ASUS, etc.), including dual-battery laptops (e.g., ThinkPad T480/T470/T460 with BAT0 & BAT1). +- **Supported Devices**: Only works on laptops with battery charge threshold + support (ThinkPad, ASUS), tested on Asus Zenbook 14 and dual-battery ThinkPads - **Permissions & Setup**: Requires write access to `/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 recommended after running - setup for new `battery_ctl` group membership changes to take effect in desktop sessions. -- **Manual Setup Fallback**: Run `sudo ./setup_rules.sh` manually from the plugin directory. -- **Persistence**: Threshold settings are stored in `thresholds.json` (and `threshold.txt`) - in the plugin data directory and restored across reboots. +- **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 + data directory and restored across reboots. diff --git a/battery-threshold/panel.luau b/battery-threshold/panel.luau index 18a59cf7..820d24a6 100644 --- a/battery-threshold/panel.luau +++ b/battery-threshold/panel.luau @@ -8,7 +8,6 @@ local function render_panel() local is_available = noctalia.state.get("is_available") == true local is_writable = noctalia.state.get("is_writable") == true - local batteries = noctalia.state.get("batteries") or {} local threshold = noctalia.state.get("current_threshold") or 0 local model_name = noctalia.state.get("battery_model_name") or "" @@ -16,35 +15,37 @@ local function render_panel() local sub_text = "" if not is_available then sub_text = noctalia.tr("panel.not-available") - elseif #batteries > 1 then - sub_text = noctalia.tr("panel.multiple-batteries", { count = #batteries }) 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.row({ align = "center", gap = 8 }, { - ui.glyph({ name = "battery-eco", size = 20, color = "primary" }), - ui.column({ gap = 2 }, { - ui.label({ - text = title_text, - fontSize = 15, - fontWeight = "bold", - color = "primary", - }), - ui.label({ - text = sub_text, - fontSize = 11, - color = "on_surface_variant", - maxLines = 1, - }), + ui.column({ gap = 2, flexGrow = 1 }, { + 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", - controlSize = "sm", onClick = "onCloseClicked", }), }), @@ -53,243 +54,78 @@ local function render_panel() if is_available then table.insert( children, - ui.separator({ thickness = 1, color = "outline/0.3", spacing = 4 }) + ui.separator({ thickness = 1, color = "outline/0.3", spacing = 8 }) ) - -- If any battery is not writable, show permission setup banner - local has_unwritable = not is_writable - if #batteries > 0 then - for _, b in ipairs(batteries) do - if b.is_writable == false then - has_unwritable = true - break - end - end - end - - if has_unwritable then + if is_writable then table.insert( children, - ui.column({ - gap = 6, - padding = 8, - radius = 8, - fill = "error/0.1", - border = "error/0.3", - borderWidth = 1, - align = "center", - }, { - ui.label({ - text = noctalia.tr("panel.read-only"), - fontSize = 11, - color = "error", - textAlign = "center", - maxLines = 2, - }), - ui.button({ - text = noctalia.tr("panel.button"), - glyph = "shield-lock", - variant = "primary", - controlSize = "sm", - onClick = "onRunSetup", - }), - }) - ) - end - - if #batteries > 1 then - -- Multi-battery display in scrollable list - local battery_cards = {} - - for _, bat in ipairs(batteries) do - local bat_name = bat.name - local bat_model = bat.model_name ~= "" and bat.model_name or "" - local bat_status = bat.status ~= "" and bat.status or "" - local bat_cap = bat.capacity or 0 - local bat_thresh = bat.current_threshold or 80 - local bat_writable = bat.is_writable == true - - local status_desc = tostring(bat_cap) .. "%" - if bat_status ~= "" and bat_status ~= "Unknown" then - status_desc = status_desc .. " · " .. bat_status - end - - local card = ui.column({ - gap = 5, - padding = 8, - radius = 8, - fill = "surface_variant/0.35", - border = "outline/0.25", - borderWidth = 1, - align = "stretch", - }, { + ui.column({ gap = 8 }, { ui.row({ align = "center", justify = "space_between" }, { - ui.row({ align = "center", gap = 6 }, { - ui.label({ - text = bat_name, - fontSize = 13, - fontWeight = "bold", - color = "primary", - }), - ui.label({ - text = bat_model ~= "" and ("(" .. bat_model .. ")") or "", - fontSize = 11, - color = "on_surface_variant", - maxLines = 1, - }), - }), ui.label({ - text = tostring(bat_thresh) .. "%", + text = title_text, fontSize = 14, - fontWeight = "bold", - color = "primary", + color = "on_surface", }), - }), - ui.row({ align = "center", justify = "space_between" }, { ui.label({ - text = status_desc, - fontSize = 10, - color = "on_surface_variant", - }), - ui.label({ - text = if bat_writable - then noctalia.tr("panel.adjust-limit") - else noctalia.tr("panel.read-only"), - fontSize = 10, - color = if bat_writable then "on_surface_variant" else "error", + text = tostring(threshold) .. "%", + fontSize = 16, + fontWeight = "bold", + color = "primary", }), }), ui.row({ align = "center", gap = 8 }, { ui.label({ text = "40%", - fontSize = 10, + fontSize = 11, color = "on_surface_variant", }), ui.slider({ min = 40, max = 100, step = 5, - value = bat_thresh, - enabled = bat_writable, + value = threshold, + enabled = is_writable, flexGrow = 1, - onChange = function(val) - local num = tonumber(val) - if num then - bat.current_threshold = num - noctalia.state.set("set_battery_threshold_request", { - name = bat_name, - value = num, - }) - render_panel() - end - end, + onChange = "onSliderChange", }), ui.label({ text = "100%", - fontSize = 10, + fontSize = 11, color = "on_surface_variant", }), }), - }) - - table.insert(battery_cards, card) - end - - table.insert( - children, - ui.scroll({ - flexGrow = 1, - gap = 8, - align = "stretch", - }, { - ui.column({ gap = 8, flexGrow = 1, align = "stretch" }, battery_cards), + ui.label({ + text = hint_text, + fontSize = 11, + color = hint_color, + textAlign = "center", + }), }) ) - - -- Quick action button to sync all batteries + else table.insert( children, - ui.row({ justify = "space_between", align = "center" }, { - ui.button({ - text = noctalia.tr("panel.sync-all"), - glyph = "refresh", - variant = "outline", - controlSize = "sm", - onClick = "onSyncAll80", + ui.column({ gap = 12, align = "center" }, { + ui.label({ + text = hint_text, + fontSize = 12, + color = "error", + textAlign = "center", + maxLines = 2, }), ui.button({ - text = "100% (" .. noctalia.tr("panel.max") .. ")", - glyph = "bolt", - variant = "ghost", - controlSize = "sm", - onClick = "onSyncAll100", + text = noctalia.tr("panel.button"), + glyph = "shield-lock", + variant = "primary", + onClick = "onRunSetup", }), }) ) - else - -- Single battery display - local single_bat = batteries[1] - local cur_thresh = if single_bat - then single_bat.current_threshold - else threshold - local writable = if single_bat then single_bat.is_writable else is_writable - - if writable then - table.insert( - children, - ui.column({ gap = 8, flexGrow = 1, align = "stretch" }, { - ui.row({ align = "center", justify = "space_between" }, { - ui.label({ - text = title_text, - fontSize = 14, - color = "on_surface", - }), - ui.label({ - text = tostring(cur_thresh) .. "%", - 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 = cur_thresh, - enabled = writable, - flexGrow = 1, - onChange = "onSliderChange", - }), - ui.label({ - text = "100%", - fontSize = 11, - color = "on_surface_variant", - }), - }), - ui.label({ - text = noctalia.tr("panel.adjust-limit"), - fontSize = 11, - color = "on_surface_variant", - textAlign = "center", - }), - }) - ) - end end end - panel.render(ui.column({ - flexGrow = 1, - gap = 8, - padding = 12, - align = "stretch", - }, children)) + panel.render(ui.column({ gap = 12, padding = 12 }, children)) end function onOpen(context: string?) @@ -310,18 +146,6 @@ function onSliderChange(value: string) end end -function onSyncAll80() - noctalia.state.set("set_threshold_request", 80) - noctalia.state.set("current_threshold", 80) - render_panel() -end - -function onSyncAll100() - noctalia.state.set("set_threshold_request", 100) - noctalia.state.set("current_threshold", 100) - render_panel() -end - function onCloseClicked() panel.close() end @@ -330,13 +154,13 @@ function onRunSetup() noctalia.state.set("run_setup_request", true) end -noctalia.state.watch("batteries", function() +noctalia.state.watch("current_threshold", function() if is_open then render_panel() end end) -noctalia.state.watch("current_threshold", function() +noctalia.state.watch("battery_model_name", function() if is_open then render_panel() end diff --git a/battery-threshold/plugin.toml b/battery-threshold/plugin.toml index 56cb8c41..668076e6 100644 --- a/battery-threshold/plugin.toml +++ b/battery-threshold/plugin.toml @@ -20,8 +20,8 @@ entry = "widget.luau" [[panel]] id = "panel" entry = "panel.luau" -width = 340 -height = 340 +width = 320 +height = 220 placement = "floating" position = "center" open_near_click = true diff --git a/battery-threshold/service.luau b/battery-threshold/service.luau index 20a53154..0394a437 100644 --- a/battery-threshold/service.luau +++ b/battery-threshold/service.luau @@ -1,18 +1,14 @@ --!nonstrict -export type BatteryInfo = { - name: string, - path: string, - model_name: string, - manufacturer: string, - capacity: number, - status: string, - is_writable: boolean, - current_threshold: number, -} - -local current_batteries: { BatteryInfo } = {} - -local function get_battery_paths(): { string } +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") @@ -33,6 +29,14 @@ 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) @@ -46,157 +50,16 @@ if not data_dir or data_dir_err then .. tostring(data_dir_err or "unknown error") ) end - local THRESHOLD_FILE_PATH: string? = if data_dir then data_dir .. "/threshold.txt" else nil -local THRESHOLDS_JSON_PATH: string? = if data_dir - then data_dir .. "/thresholds.json" - else nil - -local function load_saved_thresholds(): { [string]: number } - local saved: { [string]: number } = {} - if THRESHOLDS_JSON_PATH and noctalia.fileExists(THRESHOLDS_JSON_PATH) then - local content, err = noctalia.readFile(THRESHOLDS_JSON_PATH) - if content and not err then - local decoded = noctalia.json.decode(content) - if typeof(decoded) == "table" then - for k, v in pairs(decoded) do - local num = tonumber(v) - if num and num >= 40 and num <= 100 then - saved[tostring(k)] = num - end - end - end - end - end - - -- Fallback to legacy threshold.txt - if THRESHOLD_FILE_PATH and noctalia.fileExists(THRESHOLD_FILE_PATH) then - local content, err = noctalia.readFile(THRESHOLD_FILE_PATH) - if content and not err then - local num = tonumber(noctalia.string.trim(content)) - if num and num >= 40 and num <= 100 then - saved["_legacy"] = num - end - end - end - - return saved -end - -local function save_thresholds(saved_map: { [string]: number }) - if THRESHOLDS_JSON_PATH then - local json_str = noctalia.json.encode(saved_map, true) - if json_str then - local ok, err = noctalia.writeFile(THRESHOLDS_JSON_PATH, json_str) - if not ok then - noctalia.log("Failed to save thresholds to JSON: " .. tostring(err)) - end - end - end - - -- Also save first threshold to threshold.txt for backwards compatibility - if THRESHOLD_FILE_PATH then - local first_val = nil - for _, b in ipairs(current_batteries) do - if saved_map[b.name] then - first_val = saved_map[b.name] - break - end - end - if not first_val then - first_val = saved_map["_legacy"] - end - if first_val then - noctalia.writeFile(THRESHOLD_FILE_PATH, tostring(first_val)) - end - end -end - -local function set_battery_threshold(battery_id_or_path: string, value: number) - local v = math.floor(value + 0.5) - if v < 40 then - v = 40 - end - if v > 100 then - v = 100 - end - - local target: BatteryInfo? = nil - for _, b in ipairs(current_batteries) do - if b.name == battery_id_or_path or b.path == battery_id_or_path then - target = b - break - end - end - - if not target then - local path = battery_id_or_path - if not string.find(path, "/") then - path = "/sys/class/power_supply/" .. battery_id_or_path - end - if noctalia.fileExists(path .. "/charge_control_end_threshold") then - local name = string.match(path, "([^/]+)$") or battery_id_or_path - target = { - name = name, - path = path, - model_name = "", - manufacturer = "", - capacity = 0, - status = "", - is_writable = true, - current_threshold = 0, - } - table.insert(current_batteries, target) - end - end - - if not target then - noctalia.log("Battery not found: " .. tostring(battery_id_or_path)) +local function set_threshold(value: number) + local batteries = get_batteries() + if #batteries == 0 then return end - local threshold_file = target.path .. "/charge_control_end_threshold" - noctalia.log( - "Setting charge threshold to " - .. tostring(v) - .. "% on " - .. target.name - .. " (" - .. target.path - .. ")" - ) - - local ok, err = noctalia.writeFile(threshold_file, tostring(v) .. "\n") - if ok then - target.current_threshold = v - local saved_map = load_saved_thresholds() - saved_map[target.name] = v - saved_map["_legacy"] = v - save_thresholds(saved_map) - - noctalia.state.set("batteries", current_batteries) - noctalia.state.set( - "current_threshold", - current_batteries[1] and current_batteries[1].current_threshold or v - ) - else - noctalia.log( - "Failed to write threshold on " - .. target.name - .. ": " - .. tostring(err) - ) - noctalia.notifyError( - noctalia.tr("notification.error-title"), - noctalia.tr("notification.error-msg", { file = threshold_file }) - ) - end -end - -local function set_all_thresholds(value: number) local v = math.floor(value + 0.5) if v < 40 then v = 40 @@ -205,40 +68,43 @@ local function set_all_thresholds(value: number) v = 100 end - if #current_batteries == 0 then - local paths = get_battery_paths() - for _, p in ipairs(paths) do - set_battery_threshold(p, v) + 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 + + if any_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 - return - end - - for _, b in ipairs(current_batteries) do - set_battery_threshold(b.name, v) end end -local function set_threshold(value: number) - set_all_thresholds(value) -end - local function check_status() - local paths = get_battery_paths() - - -- Check if user configured a specific battery device - local configured = noctalia.getConfig("battery_device") - if - typeof(configured) == "string" - and configured ~= "" - and configured ~= "all" - and noctalia.fileExists(configured .. "/charge_control_end_threshold") - then - paths = { configured } - end - - if #paths == 0 then - current_batteries = {} - noctalia.state.set("batteries", {}) + 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) @@ -246,119 +112,61 @@ local function check_status() return end - local saved_map = load_saved_thresholds() - local config_val = noctalia.getConfig("charge_threshold") - local default_threshold = if typeof(config_val) == "number" - and config_val >= 40 - and config_val <= 100 - then config_val - else 80 + noctalia.state.set("is_available", true) - local new_batteries = {} - local pending_checks = #paths - local any_writable = false - local all_writable = true - - for i, path in ipairs(paths) do - local name = string.match(path, "([^/]+)$") or ("BAT" .. tostring(i - 1)) - - local model_name = "" - local model_content, _ = noctalia.readFile(path .. "/model_name") - if model_content then - model_name = noctalia.string.trim(model_content) - end - - local manufacturer = "" - local mfg_content, _ = noctalia.readFile(path .. "/manufacturer") - if mfg_content then - manufacturer = noctalia.string.trim(mfg_content) - end - - local capacity = 0 - local cap_content, _ = noctalia.readFile(path .. "/capacity") - if cap_content then - capacity = tonumber(noctalia.string.trim(cap_content)) or 0 - end - - local status = "Unknown" - local status_content, _ = noctalia.readFile(path .. "/status") - if status_content then - status = noctalia.string.trim(status_content) + 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, ", ")) - local threshold_file = path .. "/charge_control_end_threshold" - local current_val = 0 - local threshold_content = noctalia.readFile(threshold_file) - if threshold_content then - current_val = tonumber(noctalia.string.trim(threshold_content)) or 0 - 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 bat_info: BatteryInfo = { - name = name, - path = path, - model_name = model_name, - manufacturer = manufacturer, - capacity = capacity, - status = status, - is_writable = false, - current_threshold = current_val, - } - table.insert(new_batteries, bat_info) + 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) - bat_info.is_writable = writable if writable then any_writable = true - local target_threshold = saved_map[name] - or saved_map["_legacy"] - or default_threshold - if - target_threshold - and target_threshold >= 40 - and target_threshold <= 100 - then - if current_val ~= target_threshold then - local ok, _ = noctalia.writeFile( - threshold_file, - tostring(target_threshold) .. "\n" - ) - if ok then - bat_info.current_threshold = target_threshold - end - end - end - else - all_writable = false end pending_checks -= 1 if pending_checks == 0 then - current_batteries = new_batteries - noctalia.state.set("batteries", current_batteries) - noctalia.state.set("is_available", true) noctalia.state.set("is_writable", any_writable) - noctalia.state.set( - "current_threshold", - current_batteries[1] - and current_batteries[1].current_threshold - or 0 - ) - local model_summary = "" - if #current_batteries == 1 then - model_summary = current_batteries[1].model_name - elseif #current_batteries > 1 then - local models = {} - for _, b in ipairs(current_batteries) do - local label = b.name - if b.model_name ~= "" then - label = label .. ": " .. b.model_name + 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 - table.insert(models, label) end - model_summary = table.concat(models, ", ") end - noctalia.state.set("battery_model_name", model_summary) end end) end @@ -406,28 +214,7 @@ noctalia.state.watch("set_threshold_request", function(val) if val then local num = tonumber(val) if num then - set_all_thresholds(num) - end - end -end) - -noctalia.state.watch("set_battery_threshold_request", function(val) - if typeof(val) == "table" then - local bat = val.battery or val.name or val.path - local num = tonumber(val.value or val.threshold) - if bat and num then - set_battery_threshold(tostring(bat), num) - end - elseif typeof(val) == "string" then - local bat, num_str = string.match(val, "^([^:]+):(%d+)$") - if not bat then - bat, num_str = string.match(val, "^(%S+)%s+(%d+)$") - end - if bat and num_str then - local num = tonumber(num_str) - if num then - set_battery_threshold(bat, num) - end + set_threshold(num) end end end) @@ -435,21 +222,9 @@ end) function onIpc(event, payload) if event == "set" then if payload then - local payload_str = tostring(payload) - local bat, val_str = string.match(payload_str, "^(%S+)%s+(%d+)$") - if not bat then - bat, val_str = string.match(payload_str, "^([^:]+):(%d+)$") - end - if bat and val_str then - local num = tonumber(val_str) - if num and num >= 40 and num <= 100 then - set_battery_threshold(bat, num) - end - else - local val = tonumber(payload) - if val and val >= 40 and val <= 100 then - set_all_thresholds(val) - end + local val = tonumber(payload) + if val and val >= 40 and val <= 100 then + set_threshold(val) end end elseif event == "setup" then @@ -461,12 +236,4 @@ function onConfigChanged() check_status() end -function update() - check_status() -end - -if typeof(service) == "table" and typeof(service.setUpdateInterval) == "function" then - service.setUpdateInterval(5000) -end - check_status() diff --git a/battery-threshold/setup_rules.sh b/battery-threshold/setup_rules.sh index e81b4153..aaed2f6e 100755 --- a/battery-threshold/setup_rules.sh +++ b/battery-threshold/setup_rules.sh @@ -4,7 +4,7 @@ # Battery Threshold Udev Setup # ------------------------------ # This script sets up udev rules to allow a non-root user to write to -# /sys/class/power_supply/BAT*/charge_control_end_threshold. +# /sys/class/power_supply/BAT0/charge_control_end_threshold. # It creates a group 'battery_ctl' and adds the target user to this group. # # Usage: @@ -88,20 +88,12 @@ SUBSYSTEM=="power_supply", KERNEL=="BAT*", \ RUN+="/bin/chmod g+w /sys$devpath/charge_control_end_threshold" EOF -echo "Applying permissions to existing batteries..." -for threshold_file in /sys/class/power_supply/BAT*/charge_control_end_threshold; do - if [ -f "$threshold_file" ]; then - chgrp battery_ctl "$threshold_file" 2>/dev/null || true - chmod g+w "$threshold_file" 2>/dev/null || true - fi -done - echo "Reloading rules..." udevadm control --reload-rules && udevadm trigger echo "" -echo "Permissions applied! You may need to log out and log back in for new group membership to take full effect in active desktop sessions." +echo "You may need a reboot for the plugin's write access to take effect." echo "Done!" echo "" read -rp "Press Enter to exit..." diff --git a/battery-threshold/translations/en.json b/battery-threshold/translations/en.json index 0bf06503..c4195ae2 100644 --- a/battery-threshold/translations/en.json +++ b/battery-threshold/translations/en.json @@ -12,11 +12,8 @@ "panel": { "adjust-limit": "Drag slider to adjust limit", "button": "Configure Permissions", - "max": "Max", - "multiple-batteries": "{count} Batteries Detected", "not-available": "Not available on this system", "read-only": "Read-only: Install udev rule for write access", - "sync-all": "Set all to 80%", "title": "Battery Threshold" }, "settings": { diff --git a/battery-threshold/widget.luau b/battery-threshold/widget.luau index 3d071821..f3570509 100644 --- a/battery-threshold/widget.luau +++ b/battery-threshold/widget.luau @@ -1,75 +1,26 @@ --!nonstrict local function render_widget() local is_available = noctalia.state.get("is_available") == true - local batteries = noctalia.state.get("batteries") or {} local threshold = noctalia.state.get("current_threshold") or 0 local container = barWidget.isVertical() and ui.column or ui.row - if not is_available then + 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")) - return end - - local label_text = "" - local tooltip_lines = {} - - if #batteries > 1 then - local all_same = true - local first_thresh = batteries[1] and batteries[1].current_threshold or 80 - local thresh_parts = {} - - table.insert(tooltip_lines, noctalia.tr("panel.title") .. ":") - - for _, bat in ipairs(batteries) do - local b_thresh = bat.current_threshold or 80 - if b_thresh ~= first_thresh then - all_same = false - end - table.insert(thresh_parts, tostring(b_thresh) .. "%") - - local bat_desc = "• " .. bat.name - if bat.model_name ~= "" then - bat_desc = bat_desc .. " (" .. bat.model_name .. ")" - end - bat_desc = bat_desc .. ": " .. tostring(b_thresh) .. "%" - if bat.capacity then - bat_desc = bat_desc .. " [" .. tostring(bat.capacity) .. "%" - if bat.status and bat.status ~= "" and bat.status ~= "Unknown" then - bat_desc = bat_desc .. " · " .. bat.status - end - bat_desc = bat_desc .. "]" - end - table.insert(tooltip_lines, bat_desc) - end - - if all_same then - label_text = tostring(first_thresh) .. "%" - else - label_text = table.concat(thresh_parts, " / ") - end - else - label_text = tostring(threshold) .. "%" - local single_bat = batteries[1] - local tip = noctalia.tr("panel.title") .. " " .. tostring(threshold) .. "%" - if single_bat and single_bat.model_name ~= "" then - tip = tip .. " (" .. single_bat.model_name .. ")" - end - table.insert(tooltip_lines, tip) - end - - barWidget.render(container({ gap = 4, align = "center" }, { - ui.glyph({ name = "charging-pile", size = 14, color = "primary" }), - ui.label({ text = label_text, fontWeight = "bold" }), - })) - - barWidget.setTooltip(table.concat(tooltip_lines, "\n")) end -noctalia.state.watch("batteries", render_widget) noctalia.state.watch("current_threshold", render_widget) noctalia.state.watch("is_available", render_widget)