SmartestHome/stream-dock/apply-leds.sh

305 lines
11 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Make the device plugin notice a freshly-written leds.toml.
#
# apply-leds.sh --strategy <none|signal|restart-plugin|restart-opendeck>
# apply-leds.sh --probe # find out which one your machine needs
#
# WHY THIS IS A SCRIPT AND NOT A LINE OF PYTHON. The akp05 device plugin reads
# leds.toml at startup, and nothing documents it re-reading. Nothing else can drive
# those LEDs either: the plugin holds the USB device open. So "the rings update live"
# comes down to one unknown — what makes that process pick the file up again — and the
# answer is a property of your installed build, not of this repo. The probe finds it
# in about two minutes with the dock in front of you; everything else here is already
# written and tested.
#
# The strategies, cheapest first:
#
# none write the file and stop. CORRECT IF the plugin already watches
# the file — which is the FIRST thing the probe tests, because if
# it does, the rings are live for free and every strategy below is
# a worse answer.
# signal SIGHUP the plugin process. Reload-on-SIGHUP is a common daemon
# convention, so it is worth one test — but be clear-eyed: the
# DEFAULT action for an unhandled SIGHUP is to terminate the
# process, so a build that does not implement it lands you in
# restart-plugin territory with extra steps. The probe checks
# whether the process survived and says so.
# restart-plugin kill the plugin and let OpenDeck respawn it. Re-initialises the
# device: expect a visible blink and the keys redrawing. Only
# tolerable behind apply_min_interval_seconds, never per detent.
# restart-opendeck restart the whole application. The last resort — it takes the
# whole dock away for a second or two.
#
# The real fix is upstream and small: make the device plugin watch leds.toml. See
# upstream-file-watch-request.md in this directory, which is written and ready to file.
set -uo pipefail
STRATEGY="${STREAM_DOCK_LED_APPLY_STRATEGY:-none}"
PLUGIN_PATTERN="${STREAM_DOCK_PLUGIN_PATTERN:-akp05}"
PROBE=false
while [[ $# -gt 0 ]]; do
case "$1" in
--strategy) STRATEGY="${2:-none}"; shift 2 ;;
--probe) PROBE=true; shift ;;
*) echo "usage: apply-leds.sh [--strategy <name>] [--probe]" >&2; exit 2 ;;
esac
done
# The pattern is config-supplied, so it is never interpolated into a shell string:
# pgrep takes it as a separate argument, and -x is deliberately NOT used because the
# plugin's real process name is one of the things the probe is for.
#
# `pgrep -f` matches whole command lines, which includes THIS script's own and its
# parent shell's — a shell invoked as `STREAM_DOCK_PLUGIN_PATTERN=akp05 apply-leds.sh`
# carries the pattern in its command line and matches itself. Sending TERM to that is
# how a probe kills the terminal it is being run from, so every caller filters out its
# own process and its parent. Found the hard way, on the first test run.
# Every process between this script and init, so a match on an ancestor's command line
# can never be signalled. The parent alone is not enough: run the probe from a shell
# that was itself launched with the pattern on its command line and the match lands on
# the GRANDparent, which is exactly how the first test run took down the terminal.
ancestry() {
local pid=$$ ppid
while [[ -n "$pid" && "$pid" != "0" && "$pid" != "1" ]]; do
echo "$pid"
ppid="$(awk '{print $4}' "/proc/$pid/stat" 2>/dev/null)" || break
[[ -n "$ppid" ]] || break
pid="$ppid"
done
}
exclude_self() { grep -v -x -F -f <(ancestry) || true; }
plugin_pids() { pgrep -f -- "$PLUGIN_PATTERN" 2>/dev/null | exclude_self; }
opendeck_pids() { pgrep -f -- "opendeck" 2>/dev/null | exclude_self; }
do_none() { return 0; }
do_signal() {
local pids
pids="$(plugin_pids || true)"
[[ -n "$pids" ]] || { echo "apply-leds: no process matching '$PLUGIN_PATTERN'" >&2; return 1; }
# shellcheck disable=SC2086
kill -HUP $pids 2>/dev/null || return 1
return 0
}
do_restart_plugin() {
local pids
pids="$(plugin_pids || true)"
[[ -n "$pids" ]] || { echo "apply-leds: no process matching '$PLUGIN_PATTERN'" >&2; return 1; }
# TERM, never KILL: the plugin owns a USB device, and giving it the chance to close
# the handle is the difference between OpenDeck respawning cleanly and the next
# instance finding the device busy.
# shellcheck disable=SC2086
kill -TERM $pids 2>/dev/null || return 1
return 0
}
do_restart_opendeck() {
# Flatpak first, since that is how OpenDeck is normally installed on Linux, then a
# plain process. Deliberately no `systemctl --user restart` guess: OpenDeck ships no
# user unit, and inventing a name here would fail silently every time.
if command -v flatpak >/dev/null && flatpak ps --columns=application 2>/dev/null | grep -q opendeck; then
flatpak kill me.amankhanna.opendeck >/dev/null 2>&1
sleep 1
setsid flatpak run me.amankhanna.opendeck >/dev/null 2>&1 &
return 0
fi
local pids
pids="$(opendeck_pids || true)"
[[ -n "$pids" ]] || { echo "apply-leds: OpenDeck does not appear to be running" >&2; return 1; }
# shellcheck disable=SC2086
kill -TERM $pids 2>/dev/null || return 1
echo "apply-leds: OpenDeck was asked to exit — it is NOT restarted automatically" >&2
echo "apply-leds: outside a Flatpak install; start it again yourself" >&2
return 1
}
run_strategy() {
case "$1" in
none) do_none ;;
signal) do_signal ;;
restart-plugin) do_restart_plugin ;;
restart-opendeck) do_restart_opendeck ;;
*) echo "apply-leds: unknown strategy '$1'" >&2; return 2 ;;
esac
}
# ---------------------------------------------------------------------------
# Probe
# ---------------------------------------------------------------------------
if ! $PROBE; then
run_strategy "$STRATEGY"
exit $?
fi
ENV_FILE="${STREAM_DOCK_ENV_FILE:-$HOME/.config/stream-dock/led-sync.env}"
LEDS_PATH="${STREAM_DOCK_LEDS_PATH:-}"
if [[ -z "$LEDS_PATH" && -r "$ENV_FILE" ]]; then
LEDS_PATH="$(sed -n 's/^STREAM_DOCK_LEDS_PATH=//p' "$ENV_FILE" | head -1)"
fi
LEDS_PATH="${LEDS_PATH:-$HOME/.config/opendeck-akp05/leds.toml}"
LEDS_PATH="${LEDS_PATH/#\~/$HOME}"
cat <<EOF
Stream Dock ring-reload probe
=============================
LED file : ${LEDS_PATH}
Plugin : processes matching '${PLUGIN_PATTERN}'
Have the dock plugged in and OpenDeck running, and be able to see the four knob
rings. Each test writes an obvious colour and asks whether the rings changed.
EOF
if [[ ! -e "$LEDS_PATH" ]]; then
echo "warn: ${LEDS_PATH} does not exist yet — run stream-dock/setup-stream-dock.sh first." >&2
fi
echo "Processes that look like they could be the device plugin:"
ps -eo pid,comm,args 2>/dev/null | grep -iE "opendeck|akp05|streamdock|stream-dock" | grep -v grep \
| sed 's/^/ /' || true
echo " (if none of these match '${PLUGIN_PATTERN}', re-run with"
echo " STREAM_DOCK_PLUGIN_PATTERN=<something from the list above>)"
echo
if systemctl --user is-active --quiet stream-dock-led-sync 2>/dev/null; then
echo "Stopping stream-dock-led-sync for the duration of the probe, so it does not"
echo "overwrite the test colours."
systemctl --user stop stream-dock-led-sync
RESTART_SYNC=true
else
RESTART_SYNC=false
fi
restore() {
if [[ "${RESTART_SYNC:-false}" == "true" ]]; then
echo "Restarting stream-dock-led-sync."
systemctl --user start stream-dock-led-sync
fi
}
trap restore EXIT
write_test_file() {
mkdir -p "$(dirname "$LEDS_PATH")"
cat > "${LEDS_PATH}.tmp" <<EOF
# Written by apply-leds.sh --probe
brightness = 100
[mode.Static]
colors = [[$1], [$1], [$1], [$1]]
EOF
mv "${LEDS_PATH}.tmp" "$LEDS_PATH"
}
ask() {
local answer
read -r -p "$1 [y/N] " answer
[[ "$answer" == "y" || "$answer" == "Y" ]]
}
WINNER=""
echo
echo "--- Test 1 of 4: does the plugin already watch the file? (strategy: none) ---"
write_test_file "0, 255, 0"
echo "Wrote all four rings GREEN. Waiting 3 seconds."
sleep 3
if ask "Did the rings turn green?"; then
WINNER="none"
else
echo
echo "--- Test 2 of 4: SIGHUP the plugin (strategy: signal) ---"
BEFORE="$(plugin_pids | tr '\n' ' ')"
write_test_file "0, 0, 255"
if do_signal; then
sleep 3
AFTER="$(plugin_pids | tr '\n' ' ')"
if [[ -z "$AFTER" ]]; then
echo "note: the plugin process is GONE — this build does not handle SIGHUP, and the"
echo " default action terminated it. If the rings changed anyway, OpenDeck"
echo " respawned it, which is the restart-plugin strategy in disguise."
elif [[ "$BEFORE" != "$AFTER" ]]; then
echo "note: the process id changed — it was restarted, not reloaded."
fi
if ask "Did the rings turn blue?"; then
[[ -n "$AFTER" && "$BEFORE" == "$AFTER" ]] && WINNER="signal" || WINNER="restart-plugin"
fi
else
echo "signal could not be sent — skipping."
fi
fi
if [[ -z "$WINNER" ]]; then
echo
echo "--- Test 3 of 4: restart the plugin (strategy: restart-plugin) ---"
write_test_file "255, 0, 0"
if do_restart_plugin; then
echo "Asked the plugin to exit. Waiting 5 seconds for OpenDeck to respawn it."
sleep 5
if [[ -z "$(plugin_pids)" ]]; then
echo "note: nothing came back — OpenDeck does not respawn this plugin on its own,"
echo " so this strategy is not usable. The dock may need OpenDeck restarted."
fi
ask "Did the rings turn red?" && WINNER="restart-plugin"
else
echo "could not signal the plugin — skipping."
fi
fi
if [[ -z "$WINNER" ]]; then
echo
echo "--- Test 4 of 4: restart OpenDeck (strategy: restart-opendeck) ---"
echo "This takes the whole dock away for a moment."
if ask "Try it?"; then
write_test_file "255, 0, 255"
if do_restart_opendeck; then
echo "Waiting 10 seconds for OpenDeck to come back."
sleep 10
ask "Did the rings turn magenta?" && WINNER="restart-opendeck"
fi
fi
fi
echo
if [[ -n "$WINNER" ]]; then
cat <<EOF
=== Result: '${WINNER}' works ===
Set it in CoreSystemConfig.json:
"stream_dock": { "knob_leds": { "apply_strategy": "${WINNER}" } }
EOF
if [[ "$WINNER" == "none" ]]; then
cat <<'EOF'
That is the good outcome: the plugin picks the file up on its own, so the rings are
live with no reload hack at all. You can also set apply_min_interval_seconds to 0 —
there is nothing expensive to pace.
EOF
else
cat <<EOF
Keep apply_min_interval_seconds at 2 or more: '${WINNER}' re-initialises the device,
and doing that on every detent of a spun dial is how a dock ends up blinking instead
of lighting.
EOF
fi
echo
echo "Then re-run: stream-dock/setup-stream-dock.sh"
else
cat <<'EOF'
=== Result: nothing tested here makes the plugin re-read the file ===
That is a real answer, not a failure of the probe. The rings will keep showing
whatever they had at plugin start, and the fix is upstream: see
stream-dock/upstream-file-watch-request.md, which is written and ready to file
against the device plugin. Everything on this side is already done — the moment the
plugin watches the file, the rings go live with no change here.
EOF
fi