From 3ebc29c5c9c42b6bb68baa0e06db051df4eebf7c Mon Sep 17 00:00:00 2001 From: Giovanni Harting <539@idlegandalf.com> Date: Sat, 8 Nov 2025 22:35:05 +0100 Subject: [PATCH] feat: add mpv scripts --- scripts/ac-power.lua | 263 ++++++++++++++++++++++++ scripts/kb-blackout.lua | 440 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 703 insertions(+) create mode 100644 scripts/ac-power.lua create mode 100644 scripts/kb-blackout.lua diff --git a/scripts/ac-power.lua b/scripts/ac-power.lua new file mode 100644 index 0000000..e6ababa --- /dev/null +++ b/scripts/ac-power.lua @@ -0,0 +1,263 @@ +--[[ + 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 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() diff --git a/scripts/kb-blackout.lua b/scripts/kb-blackout.lua new file mode 100644 index 0000000..ad6822c --- /dev/null +++ b/scripts/kb-blackout.lua @@ -0,0 +1,440 @@ +--[[ +script-name=kb-blackout +script-description=Dims (or disables) the keyboard backlight while mpv plays media. + +Behaviors: + * Observes pause/playback/core-idle properties to decide when to dim or restore, cancelling pending timers once playback stops. + * Stores the original brightness level per session and uses `brightnessctl` exclusively for both detection and control so permissions stay centralized. + * Debounces rapid toggles, auto-detects LED devices when possible, and warns when Linux platform, device path, or privileges do not satisfy the requirements. + +Configuration (edit values here): + timeout_ms = 3000 -- delay after playback resumes before lights turn off + restore_on_pause = true -- immediately restore while paused + minimum_brightness = 0 -- fallback value if hardware rejects zero (use integer >= 0) + led_path = "" -- optional brightnessctl device override (see `brightnessctl --list`) + debounce_ms = 250 -- suppress duplicate intents within this window + brightnessctl_path = "brightnessctl" -- path to brightnessctl binary +]] + +local mp = require("mp") +local msg = require("mp.msg") +local utils = require("mp.utils") +local jit = rawget(_G, "jit") + +local config = { + timeout_ms = 3000, + restore_on_pause = true, + minimum_brightness = 0, + led_path = "", + debounce_ms = 250, + brightnessctl_path = "brightnessctl", +} + +local function coerce_boolean(value, default) + if type(value) == "boolean" then + return value + elseif type(value) == "string" then + local lowered = value:lower() + if lowered == "true" or lowered == "yes" or lowered == "1" then + return true + elseif lowered == "false" or lowered == "no" or lowered == "0" then + return false + end + elseif type(value) == "number" then + return value ~= 0 + end + return default +end + +config.timeout_ms = tonumber(config.timeout_ms) or 3000 +config.minimum_brightness = math.max(0, math.floor(tonumber(config.minimum_brightness) or 0)) +config.debounce_ms = math.max(0, tonumber(config.debounce_ms) or 250) +config.restore_on_pause = coerce_boolean(config.restore_on_pause, true) + +local function seconds(ms) + return (tonumber(ms) or 0) / 1000 +end + +local state = { + timer = nil, + playback_active = false, + is_dark = false, + original_brightness = nil, + last_intent = nil, + last_intent_ts = 0, + control_blocked = false, + filename = nil, + verified = false, + device_detection_attempted = false, + first_frame_seen = false, + pending_timer = false, +} + +local function detect_keyboard_device() + if config.led_path ~= "" then + return true + end + if state.device_detection_attempted then + return false + end + state.device_detection_attempted = true + + local result = utils.subprocess({ + args = { config.brightnessctl_path, "--list" }, + cancellable = false, + }) + + if not result or result.error or result.status ~= 0 then + msg.warn( + "Unable to auto-detect keyboard backlight (brightnessctl --list failed); set led_path manually." + ) + return false + end + + local output = result.stdout or "" + local fallback + for line in output:gmatch("[^\r\n]+") do + local name, class_name = line:match("Device '([^']+)' of class '([^']+)'") + if name and class_name and class_name:lower() == "leds" then + local lowered = name:lower() + if + lowered:find("kbd", 1, true) + or lowered:find("keyboard", 1, true) + or lowered:find("illum", 1, true) + then + config.led_path = name + msg.info(("Auto-selected keyboard LED device '%s'."):format(name)) + return true + end + fallback = fallback or name + end + end + + if fallback then + config.led_path = fallback + msg.info(("Auto-selected LED device '%s' (first LEDs class)."):format(fallback)) + return true + end + + msg.warn("No LEDs-class devices found via brightnessctl --list; set led_path manually.") + return false +end + +local function unsupported_platform() + if not jit or jit.os ~= "Linux" then + msg.warn("kb-blackout only supports Linux; disabling.") + return true + end + return false +end + +if unsupported_platform() then + return +end + +local function trim(str) + return (str and str:gsub("^%s+", ""):gsub("%s+$", "")) or "" +end + +local function build_brightnessctl_args(extra) + local args = { config.brightnessctl_path } + if config.led_path ~= nil and config.led_path ~= "" then + table.insert(args, "--device") + table.insert(args, config.led_path) + end + for _, value in ipairs(extra) do + table.insert(args, value) + end + return args +end + +local function log_permission_hint(stderr_text) + if not stderr_text or stderr_text == "" then + return + end + if stderr_text:match("Permission denied") then + msg.error( + "brightnessctl permission denied. Run `man brightnessctl` for guidance on udev/group access." + ) + elseif stderr_text:match("No such device") or stderr_text:match("Invalid device") then + if config.led_path ~= "" then + msg.error( + ("LED path '%s' not found. Run `brightnessctl --list` to discover valid names."):format( + config.led_path + ) + ) + else + msg.error( + "Keyboard backlight LED path was not found. Consider setting `led_path` (see `brightnessctl --list`)." + ) + end + end +end + +local function run_brightnessctl(extra, action) + if state.control_blocked then + return nil, "control blocked" + end + + local result = utils.subprocess({ + args = build_brightnessctl_args(extra), + cancellable = false, + }) + + if not result or result.error then + local err = result and result.error or "unknown error" + msg.error(("brightnessctl %s failed to start: %s"):format(action, err)) + state.control_blocked = true + return nil, err + end + + if result.status ~= 0 then + local stderr_text = trim(result.stderr or "") + msg.error(("brightnessctl %s failed (exit %d): %s"):format(action, result.status, stderr_text)) + log_permission_hint(stderr_text) + state.control_blocked = true + return nil, stderr_text ~= "" and stderr_text or ("exit " .. result.status) + end + + return result.stdout, nil +end + +local function probe_environment() + if state.verified then + return true + end + + local ok_stdout, err = run_brightnessctl({ "--version" }, "--version") + if not ok_stdout then + msg.error(("brightnessctl verification failed: %s"):format(err or "unknown")) + return false + end + + detect_keyboard_device() + + -- Probe LED availability without caching brightness. + local probe, probe_err = run_brightnessctl({ "-m", "get" }, "get") + if not probe then + msg.error(("Unable to detect keyboard backlight: %s"):format(probe_err or "unknown")) + return false + end + + state.verified = true + return true +end + +local function read_current_brightness() + local stdout, err = run_brightnessctl({ "-m", "get" }, "get") + if not stdout then + return nil, err + end + + local cleaned = trim(stdout) + if cleaned == "" then + msg.error("brightnessctl returned empty output") + return nil, "empty output" + end + + local direct = tonumber(cleaned) + if direct then + return direct, nil + end + + local csv_value = cleaned:match("^[^,]*,[^,]*,[^,]*,(%-?%d+)") + if csv_value then + return tonumber(csv_value), nil + end + + local last_numeric + for token in cleaned:gmatch("(%-?%d+)") do + last_numeric = tonumber(token) + end + if last_numeric then + return last_numeric, nil + end + + msg.error(("Failed to parse brightnessctl output: %s"):format(cleaned)) + return nil, "parse error" +end + +local function set_brightness(target) + local stdout, err = run_brightnessctl({ "set", tostring(target) }, ("set %d"):format(target)) + if not stdout then + return false, err + end + return true +end + +local function emit_intent(intent) + local now = mp.get_time() + if state.last_intent == intent and (now - state.last_intent_ts) * 1000 < config.debounce_ms then + msg.verbose(("Debounced intent '%s'"):format(intent)) + return false + end + state.last_intent = intent + state.last_intent_ts = now + msg.debug(("Intent: %s"):format(intent)) + return true +end + +local function cancel_timer() + if state.timer then + state.timer:kill() + state.timer = nil + end +end + +local function arm_disable_callback() + state.timer = nil + emit_intent("disable_backlight") + + if state.is_dark or state.control_blocked then + return + end + + local current, err = read_current_brightness() + if not current then + msg.error(("Unable to cache original brightness: %s"):format(err or "unknown")) + return + end + state.original_brightness = current + + local ok, set_err = set_brightness(config.minimum_brightness) + if not ok then + msg.error(("Failed to disable keyboard backlight: %s"):format(set_err or "unknown")) + return + end + + state.is_dark = true + msg.info("Keyboard backlight disabled") +end + +local function schedule_timer() + cancel_timer() + state.timer = mp.add_timeout(seconds(config.timeout_ms), arm_disable_callback) + msg.verbose(("Inactivity timer armed for %.0f ms"):format(config.timeout_ms)) +end + +local function restore_backlight(reason) + if not state.is_dark then + return + end + emit_intent("restore_backlight") + + cancel_timer() + + if not state.original_brightness then + msg.warn("Original brightness unknown; skipping restore.") + state.is_dark = false + return + end + + local ok, err = set_brightness(state.original_brightness) + if not ok then + msg.error( + ("Failed to restore keyboard backlight (%s): %s"):format( + reason or "unknown", + err or "unknown" + ) + ) + return + end + + msg.info(("Keyboard backlight restored (%s)"):format(reason or "event")) + state.is_dark = false + state.original_brightness = nil +end + +local function handle_play_event(source) + if state.control_blocked or not probe_environment() then + return + end + emit_intent("disable_backlight_request") + + state.playback_active = true + if state.first_frame_seen then + schedule_timer() + state.pending_timer = false + msg.verbose(("Playback active (%s); timer armed."):format(source or "event")) + else + state.pending_timer = true + msg.verbose( + ("Playback active (%s); waiting for first decoded frame before dimming."):format( + source or "event" + ) + ) + end +end + +local function handle_pause_event(source, paused) + state.playback_active = not paused + if paused then + cancel_timer() + if config.restore_on_pause then + restore_backlight(source or "pause") + else + msg.verbose("Paused, restore_on_pause disabled; keeping lights off.") + end + else + handle_play_event(source or "pause=no") + end +end + +local function handle_idle_event(tag) + state.playback_active = false + state.first_frame_seen = false + state.pending_timer = false + cancel_timer() + restore_backlight(tag or "idle") +end + +local function on_pause(_, value) + if value == nil then + return + end + handle_pause_event("pause", value) +end + +local function on_playback_time(_, value) + if value == nil then + return + end + if not state.first_frame_seen then + state.first_frame_seen = true + if state.pending_timer and state.playback_active and not state.timer then + schedule_timer() + state.pending_timer = false + msg.verbose("First frame decoded; inactivity timer armed.") + end + end + if not state.playback_active and not mp.get_property_native("pause") then + handle_play_event("playback-time") + end +end + +local function on_filename(_, value) + if not value or value == state.filename then + return + end + state.filename = value + state.first_frame_seen = false + state.pending_timer = false + msg.verbose(("New media: %s"):format(value)) + if not mp.get_property_native("pause") then + handle_play_event("filename") + end +end + +mp.observe_property("pause", "bool", on_pause) +mp.observe_property("playback-time", "native", on_playback_time) +mp.observe_property("filename", "string", on_filename) + +mp.register_event("end-file", function() + handle_idle_event("end-file") +end) + +mp.register_event("shutdown", function() + handle_idle_event("shutdown") +end) + +msg.info("kb-blackout loaded. Configure permissions with `man brightnessctl` if needed.")