--[[ 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. 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()