Files
mpv-scripts/scripts/ac-power.lua
Giovanni Harting 749bed7dfb refactor: improve robustness and consistency across all scripts
- kb-blackout: optimize trim(), fix log units, add jit fallback
- ac-power: extract error handling helper, add capture_stderr,
  validate timeout, add security note, rename CONFIG to config
- touch-gestures: add min_brightness config, optimize brightnessctl
  to single call, improve wpctl regex, add shutdown handler
2025-12-06 19:58:30 +01:00

315 lines
8.6 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
}
-- 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")
local last_watts
local last_display_ts = 0
local last_unit
local request_inflight = false
local backoff_failures = 0
local poll_timer
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 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)
show_message(string.format("%s: %s %s", config.display_label, value_text, unit or ""))
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
if should_render then
show_value(printable, unit)
last_unit = unit
last_watts = value
end
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
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_event("shutdown", function()
if poll_timer then
poll_timer:kill()
poll_timer = nil
end
end)
mp.register_event("file-loaded", redraw_last_value)
mp.register_event("playback-restart", redraw_last_value)
start()