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
542 lines
14 KiB
Lua
542 lines
14 KiB
Lua
--[[
|
|
ac-power.lua - displays Home Assistant AC power usage in the mpv HUD.
|
|
|
|
Features:
|
|
* Polls `/api/states/<sensor_id>` via curl with a long-lived token and trims the base URL to avoid duplicate slashes.
|
|
* Debounces HUD spam by only re-rendering when watt deltas exceed `min_delta_watts` (or `min_delta_percent`) or when `force_refresh_interval` elapses.
|
|
* Survives flaky networks with capped exponential backoff, optional HUD error messages, and redraws of the last known value after OSD clears.
|
|
|
|
Configuration:
|
|
Edit the config table below to match your Home Assistant deployment; the script ignores script-opts files.
|
|
|
|
Security:
|
|
The ha_token is a long-lived access token. Avoid committing it to version control.
|
|
Consider storing this script outside of shared repositories or using a separate config file.
|
|
]]
|
|
|
|
local config = {
|
|
sensor_id = "sensor.ac_power", -- Home Assistant entity id
|
|
ha_base_url = "http://homeassistant.local:8123",
|
|
ha_token = "REPLACE_WITH_LONG_LIVED_TOKEN",
|
|
poll_interval = 3, -- seconds between successful polls
|
|
min_delta_watts = 5, -- only show when change ≥ delta
|
|
min_delta_percent = 0, -- optional % change relative to last reading
|
|
force_refresh_interval = 30, -- seconds, even if delta small
|
|
force_refresh_obeys_threshold = true, -- skip force refresh when thresholds active
|
|
display_label = "AC Power",
|
|
units_label = "W",
|
|
use_sensor_unit = true, -- prefer HA provided unit if any
|
|
display_timeout = 3, -- seconds OSD message stays up
|
|
decimal_places = 0, -- number formatting precision
|
|
http_timeout = 2, -- curl timeout in seconds
|
|
curl_binary = "curl", -- binary used for HTTP calls
|
|
insecure_ssl = false, -- set true to allow self-signed
|
|
max_backoff = 30, -- cap between retries when failing
|
|
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.
|
|
|
|
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)
|
|
end
|
|
|
|
local function trim_trailing_slash(url)
|
|
if url:sub(-1) == "/" then
|
|
return url:sub(1, -2)
|
|
end
|
|
return url
|
|
end
|
|
|
|
local function build_url()
|
|
return string.format(
|
|
"%s/api/states/%s",
|
|
trim_trailing_slash(config.ha_base_url),
|
|
config.sensor_id
|
|
)
|
|
end
|
|
|
|
local function build_request()
|
|
local timeout = tonumber(config.http_timeout) or 2
|
|
local args = { config.curl_binary, "-sS", "-m", tostring(timeout) }
|
|
if config.insecure_ssl then
|
|
table.insert(args, "-k")
|
|
end
|
|
table.insert(args, "-H")
|
|
table.insert(args, "Authorization: Bearer " .. config.ha_token)
|
|
table.insert(args, build_url())
|
|
return {
|
|
name = "subprocess",
|
|
playback_only = false,
|
|
capture_stdout = true,
|
|
capture_stderr = true,
|
|
args = args,
|
|
}
|
|
end
|
|
|
|
local function format_value(value)
|
|
local fmt = "%0." .. tostring(config.decimal_places) .. "f"
|
|
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
|
|
if unit and unit ~= "" then
|
|
return unit
|
|
end
|
|
end
|
|
return config.units_label
|
|
end
|
|
|
|
local function show_message(text, duration)
|
|
local timeout = duration
|
|
if timeout == nil then
|
|
timeout = config.display_timeout
|
|
end
|
|
mp.osd_message(text, timeout)
|
|
last_display_ts = mp.get_time()
|
|
end
|
|
|
|
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)
|
|
if config.show_errors then
|
|
show_message(string.format("%s: -- (%s)", config.display_label, err))
|
|
end
|
|
end
|
|
|
|
local function ensure_poll_timer(delay, fn)
|
|
if poll_timer then
|
|
poll_timer:kill()
|
|
end
|
|
poll_timer = mp.add_timeout(delay, function()
|
|
poll_timer = nil
|
|
fn()
|
|
end)
|
|
end
|
|
|
|
local poll_sensor
|
|
|
|
local function schedule_retry()
|
|
backoff_failures = backoff_failures + 1
|
|
local next_delay = math.min(config.max_backoff, config.poll_interval * (2 ^ backoff_failures))
|
|
ensure_poll_timer(next_delay, poll_sensor)
|
|
end
|
|
|
|
local function handle_poll_error(message)
|
|
log("error", message)
|
|
show_error(message)
|
|
schedule_retry()
|
|
end
|
|
|
|
local function meets_delta_threshold(value)
|
|
if not value then
|
|
return false
|
|
end
|
|
if not last_watts then
|
|
return true
|
|
end
|
|
|
|
local delta = math.abs(value - last_watts)
|
|
if delta >= config.min_delta_watts then
|
|
return true
|
|
end
|
|
|
|
if config.min_delta_percent > 0 then
|
|
local baseline = math.abs(last_watts)
|
|
if baseline < 1e-6 then
|
|
baseline = math.abs(value)
|
|
end
|
|
if baseline < 1e-6 then
|
|
baseline = 1
|
|
end
|
|
local percent_delta = (delta / baseline) * 100
|
|
if percent_delta >= config.min_delta_percent then
|
|
return true
|
|
end
|
|
end
|
|
|
|
return false
|
|
end
|
|
|
|
local function thresholds_enabled()
|
|
return (config.min_delta_watts or 0) > 0 or (config.min_delta_percent or 0) > 0
|
|
end
|
|
|
|
local function handle_success(value, payload)
|
|
backoff_failures = 0
|
|
local now = mp.get_time()
|
|
local printable = value and format_value(value) or "--"
|
|
local unit = sensor_units(payload)
|
|
local force_due = config.force_refresh_interval > 0
|
|
and (now - last_display_ts) >= config.force_refresh_interval
|
|
local significant_change = meets_delta_threshold(value)
|
|
|
|
local should_render = significant_change or last_watts == nil
|
|
if not should_render and force_due then
|
|
local allow_force = true
|
|
if thresholds_enabled() and config.force_refresh_obeys_threshold then
|
|
allow_force = false
|
|
end
|
|
if allow_force then
|
|
should_render = true
|
|
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, delta)
|
|
last_unit = unit
|
|
last_watts = value
|
|
end
|
|
|
|
prev_watts = value
|
|
end
|
|
|
|
local function redraw_last_value()
|
|
if last_watts then
|
|
show_value(format_value(last_watts), last_unit)
|
|
return
|
|
end
|
|
if config.initial_message and config.initial_message ~= "" then
|
|
show_message(
|
|
string.format("%s: %s", config.display_label, config.initial_message),
|
|
config.initial_message_duration
|
|
)
|
|
end
|
|
end
|
|
|
|
poll_sensor = function()
|
|
if request_inflight then
|
|
return
|
|
end
|
|
request_inflight = true
|
|
|
|
mp.command_native_async(build_request(), function(success, result, err)
|
|
request_inflight = false
|
|
|
|
if not success or not result then
|
|
handle_poll_error(err or "request failed")
|
|
return
|
|
end
|
|
|
|
if result.status ~= 0 then
|
|
local stderr = (result.stderr and result.stderr ~= "" and result.stderr)
|
|
or ("curl exit " .. tostring(result.status))
|
|
handle_poll_error(stderr)
|
|
return
|
|
end
|
|
|
|
if not result.stdout or result.stdout == "" then
|
|
handle_poll_error("empty response from Home Assistant")
|
|
return
|
|
end
|
|
|
|
local payload = utils.parse_json(result.stdout)
|
|
if not payload then
|
|
handle_poll_error("invalid JSON payload")
|
|
return
|
|
end
|
|
|
|
local numeric_value = tonumber(payload.state)
|
|
if not numeric_value then
|
|
log("warn", "sensor state not numeric: " .. tostring(payload.state))
|
|
show_error("state unknown")
|
|
else
|
|
handle_success(numeric_value, payload)
|
|
end
|
|
|
|
backoff_failures = 0
|
|
ensure_poll_timer(config.poll_interval, poll_sensor)
|
|
end)
|
|
end
|
|
|
|
local function validate_config()
|
|
if config.ha_base_url == "" then
|
|
log("error", "ha_base_url is required")
|
|
show_error("missing base url")
|
|
return false
|
|
end
|
|
if config.sensor_id == "" then
|
|
log("error", "sensor_id is required")
|
|
show_error("missing sensor id")
|
|
return false
|
|
end
|
|
if config.ha_token == "" or config.ha_token == "REPLACE_WITH_LONG_LIVED_TOKEN" then
|
|
log("error", "ha_token must be set to a Home Assistant long-lived token")
|
|
show_error("missing HA token")
|
|
return false
|
|
end
|
|
return true
|
|
end
|
|
|
|
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),
|
|
config.initial_message_duration
|
|
)
|
|
end
|
|
ensure_poll_timer(0.1, poll_sensor)
|
|
end
|
|
|
|
mp.register_script_message("ac-power-refresh", function()
|
|
log("info", "manual refresh requested")
|
|
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)
|
|
mp.register_event("playback-restart", redraw_last_value)
|
|
|
|
start()
|