feat: add power gauge/delta/minmax to ac-power and extract shared utils

ac-power enhancements:
- Add visual block gauge [████░░░░] colored by power level
- Show delta indicator (+50W) colored by trend (green=dropping, red=rising)
- Track min/max values with persistence to ~/.cache/mpv/
- Multi-process safe file writes (merges values on save)
- New script-message: ac-power-reset-minmax

Shared library (scripts/lib/utils.lua):
- Extract common helpers: trim, clamp, rgb_to_bgr, colorize
- Update all scripts to use shared module via package.path
This commit is contained in:
2025-12-18 17:14:36 +01:00
parent f4064e013c
commit b890a4efcd
5 changed files with 298 additions and 45 deletions

View File

@@ -35,6 +35,34 @@ local config = {
show_errors = true, -- display API errors via HUD
initial_message = "Fetching AC power…", -- shown until first reading
initial_message_duration = 0, -- 0 keeps OSD up until refreshed
-- Gauge display
show_gauge = true,
gauge_width = 8, -- number of bar segments
gauge_max_watts = 1000, -- fallback max until tracked max exceeds this
gauge_use_tracked_max = true, -- scale to tracked max when available
-- Delta indicator
show_delta = true,
delta_stable_range = 5, -- watts within which delta shows as stable
-- Min/Max tracking
track_minmax = true,
show_minmax = false, -- toggle: show [min-max] inline
persist_minmax = true,
data_file = "ac-power-data.json",
-- Colors (RGB hex)
use_colors = true,
color_delta_rising = "FF4444", -- red when power increasing
color_delta_falling = "44FF44", -- green when power decreasing
color_delta_stable = "FFFFFF", -- white when stable
color_low_threshold = 200,
color_mid_threshold = 500,
color_high_threshold = 800,
color_low = "44FF44", -- green
color_mid = "FFFF44", -- yellow
color_high = "FF4444", -- red
}
-- No edits are required below unless you want to change script behavior.
@@ -43,12 +71,20 @@ local mp = require("mp")
local utils = require("mp.utils")
local msg = require("mp.msg")
package.path = mp.command_native({ "expand-path", "~~/scripts/lib/?.lua;" }) .. package.path
local lib = require("utils")
local last_watts
local last_display_ts = 0
local last_unit
local request_inflight = false
local backoff_failures = 0
local poll_timer
local prev_watts = nil
local min_watts = nil
local max_watts = nil
local data_loaded = false
local osd_clear_timer = nil
local function log(level, text)
msg[level]("ac-power: " .. text)
@@ -92,6 +128,138 @@ local function format_value(value)
return string.format(fmt, value)
end
local function get_level_color(watts)
if watts <= config.color_low_threshold then
return config.color_low
elseif watts <= config.color_mid_threshold then
return config.color_mid
else
return config.color_high
end
end
local function get_delta_color(delta)
if math.abs(delta) <= config.delta_stable_range then
return config.color_delta_stable
elseif delta > 0 then
return config.color_delta_rising
else
return config.color_delta_falling
end
end
local function get_gauge_max()
if config.gauge_use_tracked_max and max_watts and max_watts > config.gauge_max_watts then
return max_watts
end
return config.gauge_max_watts
end
local function build_gauge(watts, max_scale)
if not config.show_gauge then
return ""
end
local ratio = math.min(1, math.max(0, watts / max_scale))
local filled = math.floor(ratio * config.gauge_width + 0.5)
local empty = config.gauge_width - filled
local bar = string.rep("", filled) .. string.rep("", empty)
return "[" .. lib.colorize(bar, get_level_color(watts), config.use_colors) .. "] "
end
local function get_cache_dir()
local xdg = os.getenv("XDG_CACHE_HOME")
if xdg and xdg ~= "" then
return xdg .. "/mpv"
end
return os.getenv("HOME") .. "/.cache/mpv"
end
local function get_data_file_path()
return get_cache_dir() .. "/" .. config.data_file
end
local function ensure_cache_dir()
local dir = get_cache_dir()
os.execute('mkdir -p "' .. dir .. '"')
end
local function load_minmax()
if data_loaded or not config.persist_minmax then
return
end
data_loaded = true
local f = io.open(get_data_file_path(), "r")
if not f then
return
end
local content = f:read("*a")
f:close()
if not content or content == "" then
return
end
local data = utils.parse_json(content)
if data then
min_watts = data.min_watts
max_watts = data.max_watts
log("info", string.format("loaded min/max: %s/%s", tostring(min_watts), tostring(max_watts)))
end
end
local function save_minmax()
if not config.persist_minmax then
return
end
ensure_cache_dir()
local disk_min, disk_max = nil, nil
local f = io.open(get_data_file_path(), "r")
if f then
local content = f:read("*a")
f:close()
if content and content ~= "" then
local data = utils.parse_json(content)
if data then
disk_min, disk_max = data.min_watts, data.max_watts
end
end
end
local final_min = min_watts
local final_max = max_watts
if disk_min and (not final_min or disk_min < final_min) then
final_min = disk_min
end
if disk_max and (not final_max or disk_max > final_max) then
final_max = disk_max
end
min_watts, max_watts = final_min, final_max
f = io.open(get_data_file_path(), "w")
if f then
f:write(utils.format_json({ min_watts = final_min, max_watts = final_max }))
f:close()
end
end
local function update_minmax(watts)
if not config.track_minmax or not watts then
return
end
local changed = false
if not min_watts or watts < min_watts then
min_watts = watts
changed = true
end
if not max_watts or watts > max_watts then
max_watts = watts
changed = true
end
if changed then
save_minmax()
end
end
local function sensor_units(payload)
if config.use_sensor_unit and payload and payload.attributes then
local unit = payload.attributes.unit_of_measurement
@@ -111,8 +279,42 @@ local function show_message(text, duration)
last_display_ts = mp.get_time()
end
local function show_value(value_text, unit)
show_message(string.format("%s: %s %s", config.display_label, value_text, unit or ""))
local function show_value(value_text, unit, delta)
local watts_num = tonumber(value_text) or 0
local parts = {}
table.insert(parts, build_gauge(watts_num, get_gauge_max()))
table.insert(
parts,
lib.colorize(value_text .. (unit or ""), get_level_color(watts_num), config.use_colors)
)
if config.show_delta and delta then
local sign = delta >= 0 and "+" or ""
table.insert(
parts,
" " .. lib.colorize(sign .. format_value(delta), get_delta_color(delta), config.use_colors)
)
end
if config.show_minmax and min_watts and max_watts then
table.insert(parts, string.format(" [%s-%s]", format_value(min_watts), format_value(max_watts)))
end
local text = table.concat(parts, "")
if config.use_colors then
if osd_clear_timer then
osd_clear_timer:kill()
end
mp.set_osd_ass(0, 0, text)
osd_clear_timer = mp.add_timeout(config.display_timeout, function()
mp.set_osd_ass(0, 0, "")
osd_clear_timer = nil
end)
else
mp.osd_message(text, config.display_timeout)
end
last_display_ts = mp.get_time()
end
local function show_error(err)
@@ -199,11 +401,20 @@ local function handle_success(value, payload)
end
end
local delta = nil
if value and prev_watts then
delta = value - prev_watts
end
update_minmax(value)
if should_render then
show_value(printable, unit)
show_value(printable, unit, delta)
last_unit = unit
last_watts = value
end
prev_watts = value
end
local function redraw_last_value()
@@ -287,6 +498,7 @@ local function start()
if not validate_config() then
return
end
load_minmax()
if config.initial_message and config.initial_message ~= "" then
show_message(
string.format("%s: %s", config.display_label, config.initial_message),
@@ -301,11 +513,26 @@ mp.register_script_message("ac-power-refresh", function()
ensure_poll_timer(0.01, poll_sensor)
end)
mp.register_script_message("ac-power-reset-minmax", function()
log("info", "resetting min/max values")
min_watts = nil
max_watts = nil
save_minmax()
mp.osd_message("AC Power: min/max reset", 2)
end)
mp.register_event("shutdown", function()
if poll_timer then
poll_timer:kill()
poll_timer = nil
end
if osd_clear_timer then
osd_clear_timer:kill()
osd_clear_timer = nil
end
if config.persist_minmax then
save_minmax()
end
end)
mp.register_event("file-loaded", redraw_last_value)

View File

@@ -32,6 +32,9 @@ local msg = require("mp.msg")
local utils = require("mp.utils")
local jit = rawget(_G, "jit") or {}
package.path = mp.command_native({ "expand-path", "~~/scripts/lib/?.lua;" }) .. package.path
local lib = require("utils")
local function coerce_boolean(value, default)
if type(value) == "boolean" then
return value
@@ -136,10 +139,6 @@ if unsupported_platform() then
return
end
local function trim(str)
return str and str:match("^%s*(.-)%s*$") or ""
end
local function build_brightnessctl_args(extra)
local args = { config.brightnessctl_path }
if config.led_path ~= nil and config.led_path ~= "" then
@@ -193,7 +192,7 @@ local function run_brightnessctl(extra, action)
end
if result.status ~= 0 then
local stderr_text = trim(result.stderr or "")
local stderr_text = lib.trim(result.stderr or "")
msg.error(("brightnessctl %s failed (exit %d): %s"):format(action, result.status, stderr_text))
log_permission_hint(stderr_text)
state.control_blocked = true
@@ -233,7 +232,7 @@ local function read_current_brightness()
return nil, err
end
local cleaned = trim(stdout)
local cleaned = lib.trim(stdout)
if cleaned == "" then
msg.error("brightnessctl returned empty output")
return nil, "empty output"

44
scripts/lib/utils.lua Normal file
View File

@@ -0,0 +1,44 @@
--[[
Shared utility functions for mpv scripts.
]]
local M = {}
-- String utilities
function M.trim(str)
return str and str:match("^%s*(.-)%s*$") or ""
end
-- Math utilities
function M.clamp(value, min_v, max_v)
if value < min_v then
return min_v
end
if value > max_v then
return max_v
end
return value
end
-- ASS color utilities
function M.rgb_to_bgr(rgb)
return rgb:sub(5, 6) .. rgb:sub(3, 4) .. rgb:sub(1, 2)
end
function M.colorize(text, color_rgb, use_colors)
if use_colors == false then
return text
end
return "{\\1c&H" .. M.rgb_to_bgr(color_rgb) .. "&}" .. text .. "{\\1c&HFFFFFF&}"
end
-- Debug logging factory
function M.make_dbg(prefix, log_fn)
return function(text, config_enabled)
if config_enabled ~= false then
log_fn(prefix .. ": " .. text)
end
end
end
return M

View File

@@ -48,6 +48,9 @@ local config = {
local mp = require("mp")
local msg = require("mp.msg")
package.path = mp.command_native({ "expand-path", "~~/scripts/lib/?.lua;" }) .. package.path
local lib = require("utils")
local state = {
is_stream = false,
buffering = false,
@@ -69,21 +72,6 @@ local function dbg(text)
end
end
local function rgb_to_bgr(rgb)
local r = rgb:sub(1, 2)
local g = rgb:sub(3, 4)
local b = rgb:sub(5, 6)
return b .. g .. r
end
local function colorize(text, color_rgb)
if not config.use_colors then
return text
end
local bgr = rgb_to_bgr(color_rgb)
return "{\\1c&H" .. bgr .. "&}" .. text .. "{\\1c&HFFFFFF&}"
end
local function clear_osd()
mp.set_osd_ass(0, 0, "")
end
@@ -189,14 +177,14 @@ local function show_warning(warning_type, message, color)
state.last_warning[warning_type] = mp.get_time()
state.active_warning = warning_type
local col = color or config.color_warning
local styled = colorize("" .. message, col)
local styled = lib.colorize("" .. message, col, config.use_colors)
show_osd(styled, config.osd_duration)
dbg(("warning: %s"):format(message))
end
local function show_recovery()
if state.active_warning and config.show_recovery then
local styled = colorize("✓ Recovered", config.color_recovery)
local styled = lib.colorize("✓ Recovered", config.color_recovery, config.use_colors)
show_osd(styled, config.osd_duration)
dbg("recovery shown")
end
@@ -256,12 +244,13 @@ local function on_cache_buffering_state(_, pct)
local eta = predict_buffer_time(cache, target)
local eta_str = format_prediction(eta)
if eta_str then
local pred = colorize("(" .. eta_str .. " to resume)", config.color_info)
local pred =
lib.colorize("(" .. eta_str .. " to resume)", config.color_info, config.use_colors)
table.insert(parts, pred)
end
end
local text = table.concat(parts, " ")
local styled = colorize("" .. text, config.color_warning)
local styled = lib.colorize("" .. text, config.color_warning, config.use_colors)
show_osd(styled, config.osd_duration)
end
end
@@ -294,7 +283,8 @@ local function on_cache_duration(_, duration)
local eta = predict_buffer_time(duration, 0)
local eta_str = format_prediction(eta)
if eta_str then
local pred = colorize("(stall in " .. eta_str .. ")", config.color_info)
local pred =
lib.colorize("(stall in " .. eta_str .. ")", config.color_info, config.use_colors)
table.insert(parts, pred)
end
end

View File

@@ -50,6 +50,9 @@ local mp = require("mp")
local msg = require("mp.msg")
local utils = require("mp.utils")
package.path = mp.command_native({ "expand-path", "~~/scripts/lib/?.lua;" }) .. package.path
local lib = require("utils")
local state = {
active = false,
zone = nil,
@@ -72,16 +75,6 @@ local function dbg(text)
end
end
local function clamp(value, min_v, max_v)
if value < min_v then
return min_v
end
if value > max_v then
return max_v
end
return value
end
local function run_cmd(args)
local res = utils.subprocess({ args = args, cancellable = false })
if not res or res.error or res.status ~= 0 then
@@ -149,7 +142,7 @@ local function current_brightness_percent()
local cur, max = out:match(",(%d+),(%d+),")
local c, m = tonumber(cur), tonumber(max)
if c and m and m > 0 then
state.cached_brightness_pct = clamp((c / m) * 100, 0, 100)
state.cached_brightness_pct = lib.clamp((c / m) * 100, 0, 100)
return state.cached_brightness_pct
end
end
@@ -163,12 +156,12 @@ local function current_brightness_percent()
if not c or not m or m <= 0 then
return nil
end
state.cached_brightness_pct = clamp((c / m) * 100, 0, 100)
state.cached_brightness_pct = lib.clamp((c / m) * 100, 0, 100)
return state.cached_brightness_pct
end
local function set_brightness_percent(pct)
local target = clamp(pct, config.min_brightness, 100)
local target = lib.clamp(pct, config.min_brightness, 100)
local ok = run_cmd(brightness_args({ "set", string.format("%.2f%%", target) }))
if ok then
state.cached_brightness_pct = target
@@ -191,7 +184,7 @@ local function current_volume_percent()
if not val then
return nil
end
state.cached_volume_pct = clamp(val * 100, 0, config.max_volume)
state.cached_volume_pct = lib.clamp(val * 100, 0, config.max_volume)
return state.cached_volume_pct
elseif backend == "pactl" then
local out = run_cmd({ config.pactl_path, "get-sink-volume", "@DEFAULT_SINK@" })
@@ -203,7 +196,7 @@ local function current_volume_percent()
if not pct then
return nil
end
state.cached_volume_pct = clamp(pct, 0, config.max_volume)
state.cached_volume_pct = lib.clamp(pct, 0, config.max_volume)
return state.cached_volume_pct
end
return nil
@@ -214,7 +207,7 @@ local function set_volume_percent(pct)
if not backend then
return
end
local target = clamp(pct, 0, config.max_volume)
local target = lib.clamp(pct, 0, config.max_volume)
if backend == "wpctl" then
run_cmd({
config.wpctl_path,