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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
674 changes: 674 additions & 0 deletions battery-power-management/LICENSE

Large diffs are not rendered by default.

104 changes: 104 additions & 0 deletions battery-power-management/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Battery & Power Management

A battery status widget and control panel for **Noctalia v5**. The bar widget
shows charge percentage, live power draw in watts, and the active power profile.
Clicking it opens a panel to switch the system power profile and set the battery
charge-stop threshold — no root needed at runtime.

> Ported from the Noctalia v4 (Quickshell/QML) plugin of the same name to the v5
> Luau plugin runtime.

![Battery & Power Management widget and panel](screenshot.png)

## Plugin

Manifest id `piero-93/battery-power-management`. It ships three entries that
share one state snapshot (no Lua memory is shared between them):

- `service` — the headless entry: polls the battery, power profile, and charge
threshold, and performs every system read/write.
- `widget` — the bar widget: battery glyph, `NN% ±W.W`, and an optional profile
glyph. Click it to open the panel.
- `panel` — the control surface: power-profile switch and charge-limit slider.
Toggle it from a keybind with:

```sh
noctalia msg panel-toggle piero-93/battery-power-management:panel
```

## Features

- **Live bar widget** — battery glyph, `NN% ±W.W`, and an optional profile glyph
(leaf / scale / gauge), tinted by charge and profile state.
- **Power profiles** — one-tap switch between Power-saver / Balanced / Performance
via `powerprofilesctl`.
- **Charge threshold** — a slider to cap charging (50–100%) on hardware that
exposes `charge_control_end_threshold` (ThinkPad, ASUS, and others).
- **Time remaining** — time-to-empty / time-to-full via `upower`.

## Requirements

| Tool | Used for | If missing |
|------|----------|------------|
| `powerprofilesctl` | read/set power profile | profile controls are hidden |
| `upower` | time-to-empty/full | time remaining is hidden (watts shown instead) |

`powerprofilesctl` ships with
[power-profiles-daemon](https://gitlab.freedesktop.org/upower/power-profiles-daemon);
`upower` is packaged as `upower` on every major distro.

The charge-threshold slider additionally needs the sysfs attribute
`charge_control_end_threshold` to be present **and writable by your user** — see
Usage.

## Usage

Install this checkout as a development source and enable the plugin:

```sh
noctalia msg plugins source add dev path ~/Documents/Projects/community-plugins
noctalia msg plugins enable piero-93/battery-power-management
```

Then add the **Battery & Power Management** widget from the bar's Add-widget
picker. `.luau` edits hot-reload; `plugin.toml` changes apply on the next config
reload.

**Charge-threshold permissions (optional).** Writing the charge limit needs
write access to a root-owned sysfs file. The included script sets that up once,
without giving the plugin root at runtime:

```sh
cd ~/Documents/Projects/community-plugins/battery-power-management/scripts
sudo ./setup-threshold-permissions.sh BAT0 # use your battery, e.g. BAT1
```

Then **log out and back in**. If you skip this, everything else still works; only
the threshold slider is affected (it shows a notification on write failure).

## Settings

| Setting | Type | Default | Description |
|---------|------|---------|-------------|
| Colorize by profile | bool | `true` | Tint the widget by active profile |
| Power-saver color | color | `secondary` | Accent for the Power-saver profile |
| Performance color | color | `error` | Accent for the Performance profile |
| Show profile icon | bool | `true` | Show the profile glyph in the widget |
| Show balanced icon | bool | `false` | Also show the glyph on Balanced |
| Battery device | string | `BAT0` | sysfs battery name (`BAT0`, `BAT1`, …) |

## What it does to your system

For review transparency (this plugin is trusted, unsandboxed Luau):

- **Reads** `/sys/class/power_supply/<device>/uevent` and
`/sys/class/power_supply/<device>/charge_control_end_threshold` (poll ~2 s).
- **Runs** `powerprofilesctl get` / `powerprofilesctl set <profile>` and
`upower -i /org/freedesktop/UPower/devices/battery_<device>`.
- **Writes** `<threshold> > /sys/class/power_supply/<device>/charge_control_end_threshold`
only when you move the slider (guarded by `commandExists` and permissions).
- **No network access.**

## License

GPL-3.0
165 changes: 165 additions & 0 deletions battery-power-management/panel.luau
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
--!nonstrict

local SNAPSHOT = "battery.snapshot"
local COMMAND = "battery.command"

local snap = noctalia.state.get(SNAPSHOT) or {
percent = 0,
status = "Unknown",
watt = 0,
eta = "",
profile = "balanced",
threshold = 80,
hasThreshold = false,
hasProfile = false,
}

-- Live slider value while dragging; committed to the service on drag end.
local thresholdDraft = nil
local reqId = 0

local STATUS_KEY = {
Charging = "status.charging",
Discharging = "status.discharging",
Full = "status.full",
["Not charging"] = "status.not_charging",
Unknown = "status.unknown",
}

local function dispatch(action, value)
reqId += 1
noctalia.state.set(COMMAND, {
action = action,
value = value,
requestId = tostring(os.time()) .. "-" .. tostring(reqId),
})
end

local function batteryGlyph()
local s = snap.status
if s == "Charging" then
return "battery-charging"
elseif s == "Full" then
return "battery-charging-2"
end
local p = snap.percent or 0
if p >= 86 then
return "battery-4"
elseif p >= 56 then
return "battery-3"
elseif p >= 31 then
return "battery-2"
elseif p >= 11 then
return "battery-1"
end
return "battery"
end

local function subLine()
if snap.status == "Charging" and snap.eta ~= "" then
return noctalia.tr("panel.time_to_full", { time = snap.eta })
elseif snap.status == "Discharging" and snap.eta ~= "" then
return noctalia.tr("panel.remaining", { time = snap.eta })
end
return string.format("%.1f W", snap.watt or 0)
end

local function profileButton(profile, glyph, handler)
return ui.button({
glyph = glyph,
variant = (snap.profile == profile) and "primary" or "ghost",
onClick = handler,
})
end

local function render()
local status = STATUS_KEY[snap.status] or "status.unknown"
local threshold = thresholdDraft or snap.threshold or 80

local sections = {
ui.row({ gap = 10, align = "center", padding = 8, fill = "surface_variant/0.4", radius = 8 }, {
ui.column({ align = "center", gap = 2 }, {
ui.glyph({ name = batteryGlyph(), size = 22, color = "primary" }),
ui.label({ text = (snap.percent or 0) .. "%", fontWeight = "bold" }),
}),
ui.separator({ orientation = "vertical" }),
ui.column({ gap = 2, flexGrow = 1 }, {
ui.label({ text = noctalia.tr(status), fontWeight = "bold" }),
ui.label({ text = subLine(), fontSize = 12, color = "on_surface_variant" }),
}),
}),
}

if snap.hasProfile then
table.insert(sections, ui.label({
text = noctalia.tr("panel.power_profile"),
fontSize = 12,
color = "on_surface_variant",
}))
table.insert(sections, ui.row({ gap = 8, justify = "center" }, {
profileButton("power-saver", "leaf", "onProfilePowerSaver"),
profileButton("balanced", "scale", "onProfileBalanced"),
profileButton("performance", "gauge", "onProfilePerformance"),
}))
end

if snap.hasThreshold then
table.insert(sections, ui.label({
text = noctalia.tr("panel.charge_threshold"),
fontSize = 12,
color = "on_surface_variant",
}))
table.insert(sections, ui.row({ gap = 10, align = "center" }, {
ui.glyph({ name = "shield-heart", color = "on_surface_variant" }),
ui.slider({
min = 50,
max = 100,
step = 5,
value = threshold,
flexGrow = 1,
onChange = "onThresholdChange",
onDragEnd = "onThresholdCommit",
}),
ui.label({ text = threshold .. "%", fontWeight = "bold" }),
}))
end

panel.render(ui.column({ gap = 6, padding = 10 }, sections))
end

function onOpen(_context)
snap = noctalia.state.get(SNAPSHOT) or snap
thresholdDraft = nil
render()
end

noctalia.state.watch(SNAPSHOT, function(value)
if type(value) == "table" then
snap = value
render()
end
end)

function onProfilePowerSaver()
dispatch("set_profile", "power-saver")
end

function onProfileBalanced()
dispatch("set_profile", "balanced")
end

function onProfilePerformance()
dispatch("set_profile", "performance")
end

function onThresholdChange(value)
thresholdDraft = tonumber(value)
render()
end

function onThresholdCommit()
if thresholdDraft then
dispatch("set_threshold", thresholdDraft)
thresholdDraft = nil
end
end
70 changes: 70 additions & 0 deletions battery-power-management/plugin.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
id = "piero-93/battery-power-management"
name = "Battery & Power Management"
version = "1.0.0"
plugin_api = 3
author = "piero-93"
license = "GPL-3.0"
icon = "battery-charging"
description = "Battery widget with live power draw, power-profile switching, and charge-limit control — no root at runtime."
tags = ["bar", "panel", "system", "hardware", "indicator", "utility"]

dependencies = ["powerprofilesctl", "upower"]

[[setting]]
key = "colorize_by_profile"
type = "bool"
label_key = "settings.colorize_by_profile.label"
description_key = "settings.colorize_by_profile.description"
default = true

[[setting]]
key = "color_power_saver"
type = "color"
label_key = "settings.color_power_saver.label"
description_key = "settings.color_power_saver.description"
default = "secondary"

[[setting]]
key = "color_performance"
type = "color"
label_key = "settings.color_performance.label"
description_key = "settings.color_performance.description"
default = "error"

[[setting]]
key = "show_profile"
type = "bool"
label_key = "settings.show_profile.label"
description_key = "settings.show_profile.description"
default = true

[[setting]]
key = "show_balanced_icon"
type = "bool"
label_key = "settings.show_balanced_icon.label"
description_key = "settings.show_balanced_icon.description"
default = false

[[setting]]
key = "battery_device"
type = "string"
label_key = "settings.battery_device.label"
description_key = "settings.battery_device.description"
default = "BAT0"

[[widget]]
id = "widget"
entry = "widget.luau"

[[panel]]
id = "panel"
entry = "panel.luau"
width = 270
height = 220
placement = "attached"
position = "auto"
open_near_click = true

[[service]]
id = "service"
entry = "service.luau"
Binary file added battery-power-management/screenshot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
48 changes: 48 additions & 0 deletions battery-power-management/scripts/setup-threshold-permissions.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# ---------------------------------------------------------------------------
# Battery charge-threshold udev setup.
#
# Lets the plugin write /sys/class/power_supply/<BAT>/charge_control_end_threshold
# at runtime WITHOUT root: it creates a `battery_ctl` group, adds you to it, and
# grants that group write access to the attribute via a udev rule.
#
# Usage: sudo ./setup-threshold-permissions.sh [BATTERY_DEVICE]
# BATTERY_DEVICE defaults to BAT0 (match the plugin's "Battery device" setting).
#
# Run once, then log out and back in for the group change to take effect.
# ---------------------------------------------------------------------------
set -euo pipefail

BAT="${1:-BAT0}"
RULE_FILE="/etc/udev/rules.d/99-battery-threshold.rules"

if [ "${EUID}" -ne 0 ]; then
echo "Error: run as root, e.g. sudo $0 ${BAT}" >&2
exit 1
fi

TARGET_USER="${SUDO_USER:-}"
if [ -z "${TARGET_USER}" ]; then
echo "Error: could not determine the target user (run via sudo, not as raw root)." >&2
exit 1
fi

if ! getent group battery_ctl >/dev/null; then
echo "Creating group battery_ctl..."
groupadd battery_ctl
fi

echo "Adding ${TARGET_USER} to battery_ctl..."
usermod -aG battery_ctl "${TARGET_USER}"

echo "Writing ${RULE_FILE} for ${BAT}..."
cat >"${RULE_FILE}" <<EOF
ACTION=="add|change", SUBSYSTEM=="power_supply", KERNEL=="${BAT}", RUN+="/bin/chgrp battery_ctl /sys/class/power_supply/${BAT}/charge_control_end_threshold", RUN+="/bin/chmod 0664 /sys/class/power_supply/${BAT}/charge_control_end_threshold"
EOF

echo "Reloading udev rules..."
udevadm control --reload-rules
udevadm trigger --subsystem-match=power_supply

echo
echo "Done. Log out and back in so your new group membership applies."
Loading