-- 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