feat(noctalia): add portable status widgets and idle monitoring
This commit is contained in:
@@ -25,6 +25,9 @@ type = "alex/system-status:memory"
|
|||||||
[widget.network-rate]
|
[widget.network-rate]
|
||||||
type = "alex/system-status:network-rate"
|
type = "alex/system-status:network-rate"
|
||||||
|
|
||||||
|
[widget.idle-inhibitors]
|
||||||
|
type = "alex/system-status:inhibitors"
|
||||||
|
|
||||||
[widget.temp]
|
[widget.temp]
|
||||||
type = "sysmon"
|
type = "sysmon"
|
||||||
stat = "cpu_temp"
|
stat = "cpu_temp"
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ widget_spacing = 10
|
|||||||
enabled = true
|
enabled = true
|
||||||
fill = "surface_variant"
|
fill = "surface_variant"
|
||||||
id = "g1"
|
id = "g1"
|
||||||
members = ["caffeine", "date", "clock"]
|
members = ["idle-inhibitors", "caffeine", "date", "clock"]
|
||||||
opacity = 1.0
|
opacity = 1.0
|
||||||
padding = 6.0
|
padding = 6.0
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ widget_spacing = 10
|
|||||||
enabled = true
|
enabled = true
|
||||||
fill = "surface_variant"
|
fill = "surface_variant"
|
||||||
id = "g1"
|
id = "g1"
|
||||||
members = ["caffeine", "date", "clock"]
|
members = ["idle-inhibitors", "caffeine", "date", "clock"]
|
||||||
opacity = 1.0
|
opacity = 1.0
|
||||||
padding = 6.0
|
padding = 6.0
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
../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
|
||||||
Executable
+189
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -24,3 +24,11 @@ entry = "network-rate.luau"
|
|||||||
[[widget]]
|
[[widget]]
|
||||||
id = "gpu-mode"
|
id = "gpu-mode"
|
||||||
entry = "gpu-mode.luau"
|
entry = "gpu-mode.luau"
|
||||||
|
|
||||||
|
[[widget]]
|
||||||
|
id = "inhibitors"
|
||||||
|
entry = "inhibitors.luau"
|
||||||
|
|
||||||
|
[[panel]]
|
||||||
|
id = "inhibitors-panel"
|
||||||
|
entry = "inhibitors-panel.luau"
|
||||||
|
|||||||
Reference in New Issue
Block a user