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
This commit is contained in:
2025-12-06 19:58:30 +01:00
parent 4e90c445f7
commit 749bed7dfb
3 changed files with 216 additions and 107 deletions

View File

@@ -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)