--[[ ac-power.lua - displays Home Assistant AC power usage in the mpv HUD. Features: * Polls `/api/states/` 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. ]] 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 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 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 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()