diff --git a/.config/noctalia/10-base.toml b/.config/noctalia/10-base.toml index 0577563..e1da2a4 100644 --- a/.config/noctalia/10-base.toml +++ b/.config/noctalia/10-base.toml @@ -25,6 +25,9 @@ type = "alex/system-status:memory" [widget.network-rate] type = "alex/system-status:network-rate" +[widget.idle-inhibitors] +type = "alex/system-status:inhibitors" + [widget.temp] type = "sysmon" stat = "cpu_temp" diff --git a/.config/noctalia/90-machine.toml##class.arch_desktop b/.config/noctalia/90-machine.toml##class.arch_desktop index bb054bd..ffd0473 100644 --- a/.config/noctalia/90-machine.toml##class.arch_desktop +++ b/.config/noctalia/90-machine.toml##class.arch_desktop @@ -23,7 +23,7 @@ widget_spacing = 10 enabled = true fill = "surface_variant" id = "g1" -members = ["caffeine", "date", "clock"] +members = ["idle-inhibitors", "caffeine", "date", "clock"] opacity = 1.0 padding = 6.0 diff --git a/.config/noctalia/90-machine.toml##class.arch_laptop b/.config/noctalia/90-machine.toml##class.arch_laptop index ee6fcd0..b24212f 100644 --- a/.config/noctalia/90-machine.toml##class.arch_laptop +++ b/.config/noctalia/90-machine.toml##class.arch_laptop @@ -24,7 +24,7 @@ widget_spacing = 10 enabled = true fill = "surface_variant" id = "g1" -members = ["caffeine", "date", "clock"] +members = ["idle-inhibitors", "caffeine", "date", "clock"] opacity = 1.0 padding = 6.0 diff --git a/.config/systemd/user/default.target.wants/idle-inhibitor-monitor.service b/.config/systemd/user/default.target.wants/idle-inhibitor-monitor.service new file mode 120000 index 0000000..b5c048f --- /dev/null +++ b/.config/systemd/user/default.target.wants/idle-inhibitor-monitor.service @@ -0,0 +1 @@ +../idle-inhibitor-monitor.service \ No newline at end of file diff --git a/.config/systemd/user/idle-inhibitor-monitor.service b/.config/systemd/user/idle-inhibitor-monitor.service new file mode 100644 index 0000000..31c1b86 --- /dev/null +++ b/.config/systemd/user/idle-inhibitor-monitor.service @@ -0,0 +1,12 @@ +[Unit] +Description=Track session ScreenSaver idle inhibitors for Noctalia +After=graphical-session.target +PartOf=graphical-session.target + +[Service] +ExecStart=%h/.local/bin/idle-inhibitor-monitor +Restart=on-failure +RestartSec=2 + +[Install] +WantedBy=default.target diff --git a/.local/bin/idle-inhibitor-monitor b/.local/bin/idle-inhibitor-monitor new file mode 100755 index 0000000..8e1e698 --- /dev/null +++ b/.local/bin/idle-inhibitor-monitor @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""Track session ScreenSaver inhibitors and expose combined idle blockers as TSV. + +logind exposes idle inhibitors through systemd-inhibit. Browser video wake locks +use org.freedesktop.ScreenSaver instead, which has no list API, so this monitor +observes Inhibit/UnInhibit calls and keeps their active state in a small JSON +file for the Noctalia widget and panel. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import signal +import subprocess +import sys +from pathlib import Path +from tempfile import NamedTemporaryFile + +STATE_FILE = Path.home() / ".local/state/noctalia/idle-inhibitors.json" +SCREEN_SAVER = "org.freedesktop.ScreenSaver" + + +def write_state(inhibitors: dict[tuple[str, int], dict[str, str]]) -> None: + STATE_FILE.parent.mkdir(parents=True, exist_ok=True) + payload = {"inhibitors": list(inhibitors.values())} + with NamedTemporaryFile("w", dir=STATE_FILE.parent, delete=False) as file: + json.dump(payload, file, separators=(",", ":")) + file.write("\n") + temporary_name = file.name + os.replace(temporary_name, STATE_FILE) + + +def read_screensaver_inhibitors() -> list[dict[str, str]]: + try: + payload = json.loads(STATE_FILE.read_text()) + except (FileNotFoundError, json.JSONDecodeError): + return [] + inhibitors = payload.get("inhibitors", []) + return [item for item in inhibitors if isinstance(item, dict)] + + +def logind_inhibitors() -> list[dict[str, str]]: + result = subprocess.run( + ["systemd-inhibit", "--list", "--no-pager"], + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + return [] + + inhibitors = [] + for line in result.stdout.splitlines()[1:]: + fields = line.split(maxsplit=6) + if len(fields) < 7 or "idle" not in fields[5].split(":"): + continue + reason_and_mode = fields[6].rsplit(maxsplit=1) + inhibitors.append( + { + "source": "logind", + "who": fields[0], + "user": fields[2], + "pid": fields[3], + "comm": fields[4], + "mode": reason_and_mode[-1], + "why": reason_and_mode[0] if len(reason_and_mode) == 2 else "No reason supplied", + } + ) + return inhibitors + + +def combined_inhibitors() -> list[dict[str, str]]: + return logind_inhibitors() + read_screensaver_inhibitors() + + +def print_inhibitors() -> None: + for inhibitor in combined_inhibitors(): + values = [ + inhibitor.get("source", "screensaver"), + inhibitor.get("who", ""), + inhibitor.get("user", ""), + inhibitor.get("pid", ""), + inhibitor.get("comm", ""), + inhibitor.get("mode", ""), + inhibitor.get("why", ""), + ] + print("\t".join(value.replace("\t", " ").replace("\n", " ") for value in values)) + + +def monitor() -> None: + active: dict[tuple[str, int], dict[str, str]] = {} + pending: dict[tuple[str, int], dict[str, str]] = {} + write_state(active) + + process = subprocess.Popen( + ["stdbuf", "-oL", "busctl", "--user", "--json=short", "monitor"], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + bufsize=1, + ) + + def stop(_signal: int, _frame: object) -> None: + process.terminate() + write_state({}) + raise SystemExit(0) + + signal.signal(signal.SIGTERM, stop) + signal.signal(signal.SIGINT, stop) + + assert process.stdout is not None + for line in process.stdout: + try: + message = json.loads(line) + except json.JSONDecodeError: + continue + + message_type = message.get("type") + sender = message.get("sender", "") + payload = message.get("payload", {}).get("data", []) + + if ( + message_type == "method_call" + and message.get("interface") == SCREEN_SAVER + and message.get("member") == "Inhibit" + and len(payload) >= 2 + ): + pending[(sender, int(message.get("cookie", 0)))] = { + "source": "screensaver", + "who": str(payload[0]), + "user": "session", + "pid": "", + "comm": str(payload[0]), + "mode": "block", + "why": str(payload[1]), + } + elif ( + message_type == "method_return" + and payload + and isinstance(payload[0], int) + ): + request = (message.get("destination", ""), int(message.get("reply_cookie", 0))) + inhibitor = pending.pop(request, None) + if inhibitor is not None: + active[(request[0], int(payload[0]))] = inhibitor + write_state(active) + elif ( + message_type == "method_call" + and message.get("interface") == SCREEN_SAVER + and message.get("member") == "UnInhibit" + and payload + and isinstance(payload[0], int) + ): + cookie = int(payload[0]) + active.pop((sender, cookie), None) + # Some clients reconnect before releasing a cookie; the cookie is + # globally unique for Noctalia, so clean up that fallback as well. + for key in [key for key in active if key[1] == cookie]: + active.pop(key, None) + write_state(active) + elif ( + message_type == "signal" + and message.get("interface") == "org.freedesktop.DBus" + and message.get("member") == "NameOwnerChanged" + and len(payload) == 3 + and payload[2] == "" + ): + vanished = str(payload[0]) + for key in [key for key in active if key[0] == vanished]: + active.pop(key, None) + for key in [key for key in pending if key[0] == vanished]: + pending.pop(key, None) + write_state(active) + + +parser = argparse.ArgumentParser() +parser.add_argument("--count", action="store_true", help="print combined inhibitor count") +parser.add_argument("--list", action="store_true", help="print combined inhibitors as TSV") +arguments = parser.parse_args() + +if arguments.count: + print(len(combined_inhibitors())) +elif arguments.list: + print_inhibitors() +else: + monitor() diff --git a/.local/share/noctalia/plugins/system-status/battery-rate.luau b/.local/share/noctalia/plugins/system-status/battery-rate.luau new file mode 100644 index 0000000..477f075 --- /dev/null +++ b/.local/share/noctalia/plugins/system-status/battery-rate.luau @@ -0,0 +1,205 @@ +-- Show instantaneous battery power above an ETA based on a time-weighted, +-- five-minute rolling average. Energy values are normalized to µWh so that +-- both energy_* and charge_* battery drivers can produce an estimate. +local command = [[ +bat=/sys/class/power_supply/BAT0 + +number() { + if [ -r "$1" ]; then + value=$(cat "$1" 2>/dev/null) + case "$value" in + ''|*[!0-9]*) ;; + *) printf '%s' "$value"; return 0 ;; + esac + fi + return 1 +} + +status=$(cat "$bat/status" 2>/dev/null) || exit 1 +power=$(number "$bat/power_now") +voltage=$(number "$bat/voltage_now") + +if [ -z "$power" ]; then + current=$(number "$bat/current_now") + if [ -n "$current" ] && [ -n "$voltage" ]; then + power=$(awk -v current="$current" -v voltage="$voltage" \ + 'BEGIN { printf "%.0f", current * voltage / 1000000 }') + fi +fi + +energy_now=$(number "$bat/energy_now") +energy_full=$(number "$bat/energy_full") + +if [ -z "$energy_now" ] || [ -z "$energy_full" ]; then + charge_now=$(number "$bat/charge_now") + charge_full=$(number "$bat/charge_full") + if [ -n "$charge_now" ] && [ -n "$charge_full" ] && [ -n "$voltage" ]; then + energy_now=$(awk -v charge="$charge_now" -v voltage="$voltage" \ + 'BEGIN { printf "%.0f", charge * voltage / 1000000 }') + energy_full=$(awk -v charge="$charge_full" -v voltage="$voltage" \ + 'BEGIN { printf "%.0f", charge * voltage / 1000000 }') + fi +fi + +printf '%s\t%s\t%s\t%s\n' "$status" "$power" "$energy_now" "$energy_full" +]] + +local sample_window_seconds = 5 * 60 +local power_samples = {} +local sampled_state = nil +local request_in_flight = false + +local function renderLines(top, bottom) + barWidget.render(ui.column({ gap = 0, align = "center" }, { + ui.label({ text = top, fontSize = 10 }), + ui.label({ text = bottom, fontSize = 9 }), + })) +end + +local function resetSamples(state) + if sampled_state ~= state then + power_samples = {} + sampled_state = state + end +end + +local function addPowerSample(state, power, now) + resetSamples(state) + + local latest = power_samples[#power_samples] + if latest ~= nil and latest.time == now then + latest.power = power + else + table.insert(power_samples, { time = now, power = power }) + end + + local cutoff = now - sample_window_seconds + while #power_samples > 1 and power_samples[2].time <= cutoff do + table.remove(power_samples, 1) + end +end + +local function rollingAverage(now) + if #power_samples == 0 then + return nil, 0 + end + + if #power_samples == 1 then + return power_samples[1].power, 0 + end + + local cutoff = now - sample_window_seconds + local integral = 0 + local duration = 0 + + for index = 2, #power_samples do + local earlier = power_samples[index - 1] + local later = power_samples[index] + local interval = later.time - earlier.time + local start_time = math.max(earlier.time, cutoff) + local end_time = math.min(later.time, now) + + if interval > 0 and end_time > start_time then + local start_fraction = (start_time - earlier.time) / interval + local end_fraction = (end_time - earlier.time) / interval + local start_power = earlier.power + (later.power - earlier.power) * start_fraction + local end_power = earlier.power + (later.power - earlier.power) * end_fraction + local elapsed = end_time - start_time + + integral = integral + (start_power + end_power) * elapsed / 2 + duration = duration + elapsed + end + end + + if duration == 0 then + return power_samples[#power_samples].power, 0 + end + + return integral / duration, duration +end + +local function formatDuration(hours) + local minutes = math.max(1, math.floor(hours * 60 + 0.5)) + local whole_hours = math.floor(minutes / 60) + local remaining_minutes = minutes % 60 + + if whole_hours > 0 then + return string.format("%dh %02dm", whole_hours, remaining_minutes) + end + + return string.format("%dm", remaining_minutes) +end + +local function formatSampleDuration(seconds) + local minutes = math.floor(seconds / 60) + return string.format("%dm %02ds", minutes, seconds % 60) +end + +function update() + noctalia.setUpdateInterval(5000) + + if request_in_flight then + return + end + + request_in_flight = true + noctalia.runAsync(command, function(result) + request_in_flight = false + + if result.exitCode ~= 0 then + renderLines("—", "Unavailable") + barWidget.setTooltip("Battery rate and remaining-time estimate unavailable") + return + end + + local state, power, energy_now, energy_full = string.match( + result.stdout, + "^([^\t]+)\t([^\t]*)\t([^\t]*)\t([^\r\n]*)" + ) + power = tonumber(power) + energy_now = tonumber(energy_now) + energy_full = tonumber(energy_full) + + if state ~= "Discharging" and state ~= "Charging" then + resetSamples(state) + renderLines("—", state) + barWidget.setTooltip("Battery: " .. state) + return + end + + if power == nil or power <= 0 then + renderLines("—", "Calculating…") + barWidget.setTooltip("Battery: " .. state .. " · waiting for a power reading") + return + end + + local now = os.time() + addPowerSample(state, power, now) + local average_power, sample_duration = rollingAverage(now) + local label = "Calculating…" + + if average_power ~= nil and average_power > 0 and energy_now ~= nil and energy_full ~= nil then + local energy_remaining = energy_now + if state == "Charging" then + energy_remaining = energy_full - energy_now + end + + if energy_remaining >= 0 then + local suffix = state == "Charging" and " to full" or " left" + label = formatDuration(energy_remaining / average_power) .. suffix + end + end + + local direction = state == "Charging" and "↑" or "↓" + renderLines(string.format("%s %.1f W", direction, power / 1000000), label) + barWidget.setTooltip( + string.format( + "Battery: %s · %.1f W now · %.1f W rolling average (%s sampled of 5m)", + state, + power / 1000000, + average_power / 1000000, + formatSampleDuration(sample_duration) + ) + ) + end) +end diff --git a/.local/share/noctalia/plugins/system-status/gpu-mode.luau b/.local/share/noctalia/plugins/system-status/gpu-mode.luau new file mode 100644 index 0000000..ad57b9a --- /dev/null +++ b/.local/share/noctalia/plugins/system-status/gpu-mode.luau @@ -0,0 +1,31 @@ +-- Query EnvyControl once when this widget starts; GPU mode changes require a restart. +local command = "envycontrol --query" +local labels = { + integrated = "iGPU", + dedicated = "dGPU", + nvidia = "dGPU", + hybrid = "hybrid", +} + +barWidget.setGlyph("device-desktop") +barWidget.setText("GPU…") +barWidget.setTooltip("Reading EnvyControl GPU mode…") + +noctalia.runAsync(command, function(result) + if result.exitCode ~= 0 then + barWidget.setText("GPU?") + barWidget.setTooltip("Unable to read EnvyControl GPU mode") + return + end + + local mode = string.lower(string.match(result.stdout, "([%a_-]+)") or "") + local label = labels[mode] + if label == nil then + barWidget.setText("GPU?") + barWidget.setTooltip("Unknown EnvyControl GPU mode: " .. mode) + return + end + + barWidget.setText(label) + barWidget.setTooltip("EnvyControl GPU mode: " .. mode) +end) diff --git a/.local/share/noctalia/plugins/system-status/inhibitors-panel.luau b/.local/share/noctalia/plugins/system-status/inhibitors-panel.luau new file mode 100644 index 0000000..4bbf69c --- /dev/null +++ b/.local/share/noctalia/plugins/system-status/inhibitors-panel.luau @@ -0,0 +1,85 @@ +-- Click-through details for both logind idle inhibitors and session +-- ScreenSaver requests such as browser video wake locks. +local command = (noctalia.getenv("HOME") or "") .. "/.local/bin/idle-inhibitor-monitor --list" + +local inhibitors = {} +local status = "Loading idle inhibitors…" +local request_in_flight = false + +local function renderPanel() + local children = { + ui.column({ gap = 2 }, { + ui.label({ text = "Idle inhibitors", fontSize = 18, fontWeight = "bold" }), + ui.label({ text = status, color = "on_surface_variant" }), + }), + ui.button({ text = "Refresh", onClick = "onRefresh" }), + } + + if #inhibitors == 0 then + table.insert(children, ui.label({ text = "No process currently blocks automatic idle." })) + else + for _, inhibitor in ipairs(inhibitors) do + local title = inhibitor.comm ~= "" and inhibitor.comm or inhibitor.who + table.insert(children, ui.column({ gap = 2 }, { + ui.label({ text = string.format("%s · %s", inhibitor.source, title), fontWeight = "bold" }), + ui.label({ text = inhibitor.why ~= "" and inhibitor.why or "No reason supplied", maxLines = 2 }), + ui.label({ + text = string.format("%s · %s · PID %s", inhibitor.mode, inhibitor.user, inhibitor.pid), + color = "on_surface_variant", + fontSize = 11, + }), + })) + end + end + + panel.render(ui.scroll({ padding = 20, gap = 12 }, children)) +end + +local function refreshInhibitors() + if request_in_flight then return end + request_in_flight = true + status = "Loading idle inhibitors…" + renderPanel() + + noctalia.runAsync(command, function(result) + request_in_flight = false + inhibitors = {} + + if result.exitCode ~= 0 then + status = "Unable to inspect logind inhibitors." + renderPanel() + return + end + + for line in string.gmatch(result.stdout, "[^\r\n]+") do + local source, who, user, pid, comm, mode, why = string.match( + line, + "^([^\t]*)\t([^\t]*)\t([^\t]*)\t([^\t]*)\t([^\t]*)\t([^\t]*)\t(.*)$" + ) + if source ~= nil then + table.insert(inhibitors, { + source = source, + who = who, + user = user, + pid = pid, + comm = comm, + mode = mode, + why = why, + }) + end + end + + status = #inhibitors == 0 + and "Automatic idle is eligible." + or string.format("%d active idle inhibitor%s.", #inhibitors, #inhibitors == 1 and "" or "s") + renderPanel() + end) +end + +function onOpen() + refreshInhibitors() +end + +function onRefresh() + refreshInhibitors() +end diff --git a/.local/share/noctalia/plugins/system-status/inhibitors.luau b/.local/share/noctalia/plugins/system-status/inhibitors.luau new file mode 100644 index 0000000..37518c9 --- /dev/null +++ b/.local/share/noctalia/plugins/system-status/inhibitors.luau @@ -0,0 +1,39 @@ +-- Show whether any process blocks automatic idle. The monitor combines logind +-- idle inhibitors with session ScreenSaver requests such as browser video wake locks. +local command = (noctalia.getenv("HOME") or "") .. "/.local/bin/idle-inhibitor-monitor --count" + +local request_in_flight = false + +function update() + noctalia.setUpdateInterval(5000) + + if request_in_flight then return end + request_in_flight = true + noctalia.runAsync(command, function(result) + request_in_flight = false + + local count = tonumber(result.stdout) + if result.exitCode ~= 0 or count == nil then + barWidget.setGlyph("circle-help") + barWidget.setText("Idle ?") + barWidget.setTooltip("Unable to inspect idle inhibitors") + return + end + + if count > 0 then + barWidget.setGlyph("moon-off") + barWidget.setText(string.format("Idle %d", count)) + barWidget.setTooltip( + string.format("%d idle inhibitor%s active · click for details", count, count == 1 and "" or "s") + ) + else + barWidget.setGlyph("moon") + barWidget.setText("Idle") + barWidget.setTooltip("No active idle inhibitors · click for details") + end + end) +end + +function onClick() + noctalia.runAsync("noctalia msg panel-toggle alex/system-status:inhibitors-panel") +end diff --git a/.local/share/noctalia/plugins/system-status/network-rate.luau b/.local/share/noctalia/plugins/system-status/network-rate.luau new file mode 100644 index 0000000..a065420 --- /dev/null +++ b/.local/share/noctalia/plugins/system-status/network-rate.luau @@ -0,0 +1,119 @@ +-- Compact, two-line network throughput widget. It samples the interface used +-- by the default route so virtual and physical interfaces are not double-counted. +local command = [[ +iface=$(ip route show default 2>/dev/null | awk '$1 == "default" { for (i = 1; i <= NF; i++) if ($i == "dev") { print $(i + 1); exit } }') + +if [ -z "$iface" ]; then + for path in /sys/class/net/*; do + candidate=${path##*/} + [ "$candidate" = "lo" ] && continue + [ "$(cat "$path/carrier" 2>/dev/null)" = "1" ] || continue + iface=$candidate + break + done +fi + +[ -n "$iface" ] || exit 1 +awk -v iface="$iface" '$1 ~ ("^" iface ":") { sub(":", "", $1); print iface "\t" $2 "\t" $10; exit }' /proc/net/dev +]] + +local previous = nil +local request_in_flight = false +-- 82px accommodates the longest fixed field ("1023 KiB/s") at 10px while +-- removing the excess side padding from the original 112px capsule. +local label_width = 82 + +local function formatRate(bytes_per_second) + local units = { "B/s", "KiB/s", "MiB/s", "GiB/s" } + local value = bytes_per_second + local unit_index = 1 + + while value >= 1024 and unit_index < #units do + value = value / 1024 + unit_index = unit_index + 1 + end + + if unit_index == 1 then + return string.format("%d %s", math.floor(value + 0.5), units[unit_index]) + end + + if value >= 100 then + return string.format("%.0f %s", value, units[unit_index]) + end + + return string.format("%.1f %s", value, units[unit_index]) +end + +local function renderLines(download, upload) + -- The explicit minimum width and fixed ten-character value field keep the + -- capsule width stable while inheriting the bar font used by other modules. + barWidget.render(ui.column({ gap = 0, align = "center", minWidth = label_width }, { + ui.label({ + text = string.format("↓ %10s", download), + maxLines = 1, + fontSize = 10, + }), + ui.label({ + text = string.format("↑ %10s", upload), + maxLines = 1, + fontSize = 10, + }), + })) +end + +function update() + noctalia.setUpdateInterval(2000) + + if request_in_flight then + return + end + + request_in_flight = true + noctalia.runAsync(command, function(result) + request_in_flight = false + + if result.exitCode ~= 0 then + previous = nil + renderLines("—", "—") + barWidget.setTooltip("Network throughput unavailable: no active interface") + return + end + + local iface, received, sent = string.match( + result.stdout, + "^([^\t]+)\t(%d+)\t(%d+)" + ) + received = tonumber(received) + sent = tonumber(sent) + + if iface == nil or received == nil or sent == nil then + previous = nil + renderLines("—", "—") + barWidget.setTooltip("Network throughput unavailable") + return + end + + local now = os.time() + if previous == nil or previous.iface ~= iface or now <= previous.time then + previous = { iface = iface, received = received, sent = sent, time = now } + renderLines("Sampling…", "Sampling…") + barWidget.setTooltip("Sampling network throughput on " .. iface) + return + end + + local elapsed = now - previous.time + local download = math.max(0, (received - previous.received) / elapsed) + local upload = math.max(0, (sent - previous.sent) / elapsed) + previous = { iface = iface, received = received, sent = sent, time = now } + + renderLines(formatRate(download), formatRate(upload)) + barWidget.setTooltip( + string.format( + "%s · Download %s · Upload %s", + iface, + formatRate(download), + formatRate(upload) + ) + ) + end) +end diff --git a/.local/share/noctalia/plugins/system-status/plugin.toml b/.local/share/noctalia/plugins/system-status/plugin.toml index f0096fd..f17d76d 100644 --- a/.local/share/noctalia/plugins/system-status/plugin.toml +++ b/.local/share/noctalia/plugins/system-status/plugin.toml @@ -24,3 +24,11 @@ entry = "network-rate.luau" [[widget]] id = "gpu-mode" entry = "gpu-mode.luau" + +[[widget]] +id = "inhibitors" +entry = "inhibitors.luau" + +[[panel]] +id = "inhibitors-panel" +entry = "inhibitors-panel.luau"