ed063ab1fe
Capture desktop-specific system, session, Noctalia, and Pi integration alongside shared laptop support. Retire obsolete X11 configuration and make package ownership explicit for repeatable Decman deployments.
206 lines
5.9 KiB
Lua
206 lines
5.9 KiB
Lua
-- 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
|