From 749bed7dfb4970a10a0153c8286ab172c84286b0 Mon Sep 17 00:00:00 2001 From: Giovanni Harting <539@idlegandalf.com> Date: Sat, 6 Dec 2025 19:58:30 +0100 Subject: [PATCH] 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 --- scripts/ac-power.lua | 109 ++++++++++---------- scripts/kb-blackout.lua | 9 +- scripts/touch-gestures.lua | 205 ++++++++++++++++++++++++++++--------- 3 files changed, 216 insertions(+), 107 deletions(-) diff --git a/scripts/ac-power.lua b/scripts/ac-power.lua index f28cc3b..9aa396c 100644 --- a/scripts/ac-power.lua +++ b/scripts/ac-power.lua @@ -7,10 +7,14 @@ * 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. + 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 = { +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", @@ -60,58 +64,60 @@ end local function build_url() return string.format( "%s/api/states/%s", - trim_trailing_slash(CONFIG.ha_base_url), - CONFIG.sensor_id + 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 + 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, "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" + 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 + 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 + return config.units_label end local function show_message(text, duration) local timeout = duration if timeout == nil then - timeout = CONFIG.display_timeout + 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 "")) + 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)) + if config.show_errors then + show_message(string.format("%s: -- (%s)", config.display_label, err)) end end @@ -125,6 +131,20 @@ local function ensure_poll_timer(delay, 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 @@ -134,11 +154,11 @@ local function meets_delta_threshold(value) end local delta = math.abs(value - last_watts) - if delta >= CONFIG.min_delta_watts then + if delta >= config.min_delta_watts then return true end - if CONFIG.min_delta_percent > 0 then + if config.min_delta_percent > 0 then local baseline = math.abs(last_watts) if baseline < 1e-6 then baseline = math.abs(value) @@ -147,7 +167,7 @@ local function meets_delta_threshold(value) baseline = 1 end local percent_delta = (delta / baseline) * 100 - if percent_delta >= CONFIG.min_delta_percent then + if percent_delta >= config.min_delta_percent then return true end end @@ -156,7 +176,7 @@ local function meets_delta_threshold(value) end local function thresholds_enabled() - return (CONFIG.min_delta_watts or 0) > 0 or (CONFIG.min_delta_percent or 0) > 0 + return (config.min_delta_watts or 0) > 0 or (config.min_delta_percent or 0) > 0 end local function handle_success(value, payload) @@ -164,14 +184,14 @@ local function handle_success(value, payload) 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 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 + if thresholds_enabled() and config.force_refresh_obeys_threshold then allow_force = false end if allow_force then @@ -191,15 +211,15 @@ local function redraw_last_value() show_value(format_value(last_watts), last_unit) return end - if CONFIG.initial_message and CONFIG.initial_message ~= "" then + 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 + string.format("%s: %s", config.display_label, config.initial_message), + config.initial_message_duration ) end end -local function poll_sensor() +poll_sensor = function() if request_inflight then return end @@ -209,42 +229,25 @@ local function poll_sensor() 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) + handle_poll_error(err or "request failed") 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) + handle_poll_error(stderr) 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) + handle_poll_error("empty response from Home Assistant") 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) + handle_poll_error("invalid JSON payload") return end @@ -257,22 +260,22 @@ local function poll_sensor() end backoff_failures = 0 - ensure_poll_timer(CONFIG.poll_interval, poll_sensor) + ensure_poll_timer(config.poll_interval, poll_sensor) end) end local function validate_config() - if CONFIG.ha_base_url == "" then + 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 + 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 + 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 @@ -284,10 +287,10 @@ local function start() if not validate_config() then return end - if CONFIG.initial_message and CONFIG.initial_message ~= "" then + 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 + string.format("%s: %s", config.display_label, config.initial_message), + config.initial_message_duration ) end ensure_poll_timer(0.1, poll_sensor) diff --git a/scripts/kb-blackout.lua b/scripts/kb-blackout.lua index f1a46a4..ccce8d5 100644 --- a/scripts/kb-blackout.lua +++ b/scripts/kb-blackout.lua @@ -30,7 +30,7 @@ local config = { local mp = require("mp") local msg = require("mp.msg") local utils = require("mp.utils") -local jit = rawget(_G, "jit") +local jit = rawget(_G, "jit") or {} local function coerce_boolean(value, default) if type(value) == "boolean" then @@ -137,7 +137,7 @@ if unsupported_platform() then end local function trim(str) - return (str and str:gsub("^%s+", ""):gsub("%s+$", "")) or "" + return str and str:match("^%s*(.-)%s*$") or "" end local function build_brightnessctl_args(extra) @@ -315,8 +315,9 @@ 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)) + local delay = seconds(config.timeout_ms) + state.timer = mp.add_timeout(delay, arm_disable_callback) + msg.verbose(("Inactivity timer armed for %.1f s"):format(delay)) end local function restore_backlight(reason) diff --git a/scripts/touch-gestures.lua b/scripts/touch-gestures.lua index d025b58..d59b258 100644 --- a/scripts/touch-gestures.lua +++ b/scripts/touch-gestures.lua @@ -2,10 +2,12 @@ touch-gestures.lua - tablet-friendly swipe controls for mpv. Features: + * Horizontal swipes anywhere seek forward/backward. * Vertical swipes on the right edge raise/lower system volume; swipes on the left edge adjust screen brightness via brightnessctl. + * Gesture axis is locked on first significant movement, preventing accidental cross-axis actions. * Prefers PipeWire (`wpctl`) for volume, falls back to PulseAudio (`pactl`) automatically. * Configurable gesture zones, pixels-per-step sensitivity, per-control step sizes, and optional inverted direction. - * Shows OSD feedback after each adjustment so you know the new volume/brightness values. + * Shows OSD feedback after each adjustment. Configuration: Edit the `config` table below to tune zone widths, step sizes, and OSD duration; script-opts files are ignored. @@ -17,10 +19,14 @@ local config = { deadzone_px = 4, -- ignore tiny jiggles before the first step (lower = snappier) volume_step = 5, -- percent per step brightness_step = 5, -- screen brightness percent per step + seek_step = 5, -- seconds per seek step max_volume = 130, -- clamp volume to this ceiling - invert_vertical = false, -- true flips the swipe direction + min_brightness = 1, -- minimum brightness percent (avoid turning off screen) + invert_vertical = false, -- true flips the vertical swipe direction + invert_horizontal = false, -- true flips the horizontal swipe direction left_zone_ratio = 0.35, -- fraction of width reserved for brightness (left side) right_zone_ratio = 0.35, -- fraction of width reserved for volume (right side) + top_exclude_ratio = 0, -- ignore top portion (e.g., title bar); 0 disables bottom_exclude_ratio = 0.15, -- ignore bottom portion (e.g., OSC area); 0 disables osd_duration = 1.2, -- seconds OSD stays visible; set 0 to disable log_debug = false, -- set true to log gesture details @@ -37,14 +43,17 @@ local mp = require("mp") local msg = require("mp.msg") local utils = require("mp.utils") -local show_osd - local state = { active = false, zone = nil, + axis = nil, -- "horizontal" or "vertical", locked on first significant movement accum = 0, last_x = 0, last_y = 0, + start_x = 0, -- initial touch position for axis detection + start_y = 0, + button_down = false, -- track if mouse button is held (actual touch vs cursor movement) + forwarded_down = false, -- track if we forwarded a click to mpv (need to forward up too) volume_backend = nil, -- "wpctl" or "pactl" cached_volume_pct = nil, cached_brightness_pct = nil, @@ -118,10 +127,25 @@ local function brightness_args(extra) return args end +local function show_osd(label, value) + if config.osd_duration > 0 then + mp.osd_message(string.format("%s: %s", label, value), config.osd_duration) + end +end + local function current_brightness_percent() if state.cached_brightness_pct ~= nil then return state.cached_brightness_pct end + local out = run_cmd(brightness_args({ "-m", "info" })) + if out then + local cur, max = out:match(",(%d+),(%d+),") + local c, m = tonumber(cur), tonumber(max) + if c and m and m > 0 then + state.cached_brightness_pct = clamp((c / m) * 100, 0, 100) + return state.cached_brightness_pct + end + end local cur = run_cmd(brightness_args({ "get" })) local max = run_cmd(brightness_args({ "max" })) if not cur or not max then @@ -137,7 +161,7 @@ local function current_brightness_percent() end local function set_brightness_percent(pct) - local target = clamp(pct, 0, 100) + local target = clamp(pct, config.min_brightness, 100) local ok = run_cmd(brightness_args({ "set", string.format("%.2f%%", target) })) if ok then state.cached_brightness_pct = target @@ -155,7 +179,7 @@ local function current_volume_percent() if not out then return nil end - local val = out:match("(%d?%.?%d+)") + local val = out:match("Volume:%s*([%d%.]+)") or out:match("([%d%.]+)") val = val and tonumber(val) if not val then return nil @@ -219,10 +243,18 @@ local function pick_zone(x, y, width, height) if width <= 0 then return nil end - if height and height > 0 and config.bottom_exclude_ratio > 0 then - local cutoff = height * (1 - config.bottom_exclude_ratio) - if y >= cutoff then - return nil + if height and height > 0 then + if config.top_exclude_ratio > 0 then + local top_cutoff = height * config.top_exclude_ratio + if y <= top_cutoff then + return nil + end + end + if config.bottom_exclude_ratio > 0 then + local bottom_cutoff = height * (1 - config.bottom_exclude_ratio) + if y >= bottom_cutoff then + return nil + end end end local left_limit = width * config.left_zone_ratio @@ -236,12 +268,6 @@ local function pick_zone(x, y, width, height) return nil end -function show_osd(label, value) - if config.osd_duration > 0 then - mp.osd_message(string.format("%s: %s", label, value), config.osd_duration) - end -end - local function apply_steps(kind, steps) if steps == 0 then return @@ -260,6 +286,10 @@ local function apply_steps(kind, steps) end local next_value = current + (steps * config.brightness_step) set_brightness_percent(next_value) + elseif kind == "seek" then + local seconds = steps * config.seek_step + mp.commandv("seek", seconds, "relative") + show_osd("Seek", string.format("%+ds", seconds)) end end @@ -269,18 +299,33 @@ local function reset_state() end state.active = false state.zone = nil + state.axis = nil state.accum = 0 state.last_x = 0 state.last_y = 0 + state.start_x = 0 + state.start_y = 0 end -local function start_gesture(pos, zone) +local function begin_gesture_tracking(pos) state.active = true - state.zone = zone + state.zone = nil + state.axis = nil state.accum = 0 state.last_x = pos.x state.last_y = pos.y - dbg(string.format("start gesture zone=%s at (%.0f,%.0f)", zone, pos.x, pos.y)) + state.start_x = pos.x + state.start_y = pos.y + state.cached_volume_pct = nil + state.cached_brightness_pct = nil + dbg(string.format("begin tracking at (%.0f,%.0f)", pos.x, pos.y)) +end + +local function lock_axis(axis, zone) + state.axis = axis + state.zone = zone + state.accum = 0 + dbg(string.format("locked axis=%s zone=%s", axis, tostring(zone))) end local function update_gesture(_, pos) @@ -288,48 +333,62 @@ local function update_gesture(_, pos) return end - if not state.active and config.start_on_move then - local width, height = surface_size() - local zone = pick_zone(pos.x, pos.y, width, height) - if zone then - start_gesture(pos, zone) - else - return - end + if not state.active and config.start_on_move and state.button_down then + begin_gesture_tracking(pos) end if not state.active then return end + local dx = pos.x - state.last_x local dy = pos.y - state.last_y - if config.invert_vertical then - dy = -dy - end - state.last_x = pos.x state.last_y = pos.y - if math.abs(state.accum) < config.deadzone_px then - state.accum = state.accum + dy - if math.abs(state.accum) < config.deadzone_px then + if not state.axis then + local total_dx = math.abs(pos.x - state.start_x) + local total_dy = math.abs(pos.y - state.start_y) + + if total_dx >= config.deadzone_px or total_dy >= config.deadzone_px then + if total_dx > total_dy then + lock_axis("horizontal", "seek") + else + local width, height = surface_size() + local zone = pick_zone(state.start_x, state.start_y, width, height) + if zone then + lock_axis("vertical", zone) + else + reset_state() + return + end + end + else return end - -- once deadzone crossed, drop the deadzone offset so first step is clean - if state.accum > 0 then - state.accum = state.accum - config.deadzone_px - else - state.accum = state.accum + config.deadzone_px + end + + local delta + if state.axis == "horizontal" then + delta = dx + if config.invert_horizontal then + delta = -delta end else - state.accum = state.accum + dy + delta = dy + if config.invert_vertical then + delta = -delta + end end + state.accum = state.accum + delta + local step_pixels = config.pixels_per_step if config.steps_per_screen and config.steps_per_screen > 0 then - local _, surf_h = surface_size() - if surf_h and surf_h > 0 then - step_pixels = surf_h / config.steps_per_screen + local surf_w, surf_h = surface_size() + local ref_size = state.axis == "horizontal" and surf_w or surf_h + if ref_size and ref_size > 0 then + step_pixels = ref_size / config.steps_per_screen end end if step_pixels <= 0 then @@ -341,40 +400,86 @@ local function update_gesture(_, pos) if whole_steps ~= 0 then dbg( - string.format("zone=%s dy=%.2f accum=%.2f steps=%d", state.zone, dy, state.accum, whole_steps) + string.format( + "zone=%s axis=%s delta=%.2f accum=%.2f steps=%d", + state.zone, + state.axis, + delta, + state.accum, + whole_steps + ) ) - apply_steps(state.zone, -whole_steps) + local sign = state.axis == "horizontal" and 1 or -1 + apply_steps(state.zone, sign * whole_steps) state.accum = state.accum - (whole_steps * step_pixels) end end local forwarding_click = false +local function in_excluded_zone(pos) + local _, height = surface_size() + if not height or height <= 0 then + return false + end + if config.top_exclude_ratio > 0 then + local top_cutoff = height * config.top_exclude_ratio + if pos.y <= top_cutoff then + return true + end + end + if config.bottom_exclude_ratio > 0 then + local bottom_cutoff = height * (1 - config.bottom_exclude_ratio) + if pos.y >= bottom_cutoff then + return true + end + end + return false +end + local function handle_touch(event) if forwarding_click then return end if event.event == "down" then + state.button_down = true + state.forwarded_down = false local pos = mp.get_property_native("mouse-pos") if not pos or not pos.x or not pos.y then return end - local width, height = surface_size() - local zone = pick_zone(pos.x, pos.y, width, height) - if not zone then + if in_excluded_zone(pos) then + state.forwarded_down = true forwarding_click = true - mp.commandv("keypress", "mouse_btn0") + mp.commandv("keydown", "mouse_btn0") forwarding_click = false return end - start_gesture(pos, zone) + begin_gesture_tracking(pos) elseif event.event == "up" then + if state.forwarded_down then + forwarding_click = true + mp.commandv("keyup", "mouse_btn0") + forwarding_click = false + elseif state.active and not state.axis then + forwarding_click = true + mp.commandv("keypress", "mouse_btn0") + forwarding_click = false + end + state.button_down = false + state.forwarded_down = false reset_state() end end mp.add_forced_key_binding("mouse_btn0", "touch-gestures", handle_touch, { complex = true }) mp.observe_property("mouse-pos", "native", update_gesture) + +mp.register_event("shutdown", function() + reset_state() +end) + +pick_volume_backend()