feat: add mpv scripts
This commit is contained in:
263
scripts/ac-power.lua
Normal file
263
scripts/ac-power.lua
Normal file
@@ -0,0 +1,263 @@
|
||||
--[[
|
||||
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 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.
|
||||
]]
|
||||
|
||||
local mp = require("mp")
|
||||
local utils = require("mp.utils")
|
||||
local msg = require("mp.msg")
|
||||
|
||||
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
|
||||
force_refresh_interval = 30, -- seconds, even if delta small
|
||||
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
|
||||
}
|
||||
|
||||
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 args = { CONFIG.curl_binary, "-sS", "-m", tostring(CONFIG.http_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,
|
||||
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 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 = value
|
||||
and (not last_watts or math.abs(value - last_watts) >= CONFIG.min_delta_watts)
|
||||
|
||||
if significant_change or force_due or last_watts == nil 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
|
||||
|
||||
local function poll_sensor()
|
||||
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
|
||||
backoff_failures = backoff_failures + 1
|
||||
local message = err or "request failed"
|
||||
log("error", message)
|
||||
show_error(message)
|
||||
local next_delay = math.min(CONFIG.max_backoff, CONFIG.poll_interval * (2 ^ backoff_failures))
|
||||
ensure_poll_timer(next_delay, poll_sensor)
|
||||
return
|
||||
end
|
||||
|
||||
if result.status ~= 0 then
|
||||
backoff_failures = backoff_failures + 1
|
||||
local stderr = (result.stderr and result.stderr ~= "" and result.stderr)
|
||||
or ("curl exit " .. tostring(result.status))
|
||||
log("error", stderr)
|
||||
show_error(stderr)
|
||||
local next_delay = math.min(CONFIG.max_backoff, CONFIG.poll_interval * (2 ^ backoff_failures))
|
||||
ensure_poll_timer(next_delay, poll_sensor)
|
||||
return
|
||||
end
|
||||
|
||||
if not result.stdout or result.stdout == "" then
|
||||
backoff_failures = backoff_failures + 1
|
||||
log("error", "empty response from Home Assistant")
|
||||
show_error("empty response")
|
||||
local next_delay = math.min(CONFIG.max_backoff, CONFIG.poll_interval * (2 ^ backoff_failures))
|
||||
ensure_poll_timer(next_delay, poll_sensor)
|
||||
return
|
||||
end
|
||||
|
||||
local payload = utils.parse_json(result.stdout)
|
||||
if not payload then
|
||||
backoff_failures = backoff_failures + 1
|
||||
log("error", "invalid JSON payload")
|
||||
show_error("invalid JSON")
|
||||
local next_delay = math.min(CONFIG.max_backoff, CONFIG.poll_interval * (2 ^ backoff_failures))
|
||||
ensure_poll_timer(next_delay, poll_sensor)
|
||||
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()
|
||||
Reference in New Issue
Block a user