kbd-disp-switch - kbd-disp-switch - shared-monitor HDMI input switching (udev + ddcutil)

About | Log | Files | Refs | License

kbd_disp_switch (6497B)


#!/bin/sh
# kbd_disp_switch — shared-monitor HDMI input switching, arrival-driven
#
# Two PCs share one monitor (e.g. HDMI-1 = host A, HDMI-2 = host B) and move
# one USB dongle (keyboard/mouse receiver) between them. The display follows
# the dongle's ARRIVAL:
#   attach  -> switch to MY_INPUT ... unless the dongle came straight back
#              from this host (round trip / flap): the display never moved,
#              so a re-write would only cause a 2-3 s blank (re-handshake).
#   detach  -> record the departure time; push it to the peer host.
#
# Decision (event-ordering, NOT a time window):
#   no-op iff my_D > 0 AND peer_D > 0 AND my_D > peer_D
#   (my departure newer than the peer's last departure -> round trip)
#   otherwise switch (safe default when the peer is unknown)
#
# Subcommands (called by udev rule 99-kbd-disp.rules and systemd-run):
#   attach  — udev add: cancel pending decision, start the decision unit,
#              fire-and-forget peer push
#   detach  — udev remove: record departure, cancel pending decision, push
#              departure to the peer (synchronous)
#   decide  — systemd-run unit: peer query + event-ordering decision + DDC write
#   push    — systemd-run unit: fire-and-forget peer push
#
# Why systemd-run? udev kills leftover processes of RUN programs (cgroup
# kill — even setsid doesn't help), so backgrounded jobs die. Transient
# systemd units survive and are cancellable via systemctl stop. The decision
# unit sleeps SETTLE internally (systemd --on-active timers proved unreliable
# here — they fired 6-60 s late).
#
# Config: /etc/kbd_disp.conf
#   MY_INPUT=1|2        1 -> x0090 (monitor HDMI 1), 2 -> x0091 (HDMI 2)
#   SETTLE=2            seconds before the decision (dedupes USB events)
#   PEER_QUERY=user@host         (optional) ssh-query peer's last_detach
#   PEER_STATE_FILE=/path        (optional, passive mode) last_detach pushed by peer
#   PEER_PUSH=user@host          (optional) push our last_detach to peer
#   SSH_KEY=/root/.ssh/kbd_disp  dedicated passphrase-less key (udev runs as root)
#   DDC_CMD='...'               DDC write command; %s = input value (see README)
#
# Default DDC write (LG side-channel — standard VCP 60 is broken on recent
# LG monitors; see README "Finding the right DDC command"):
#   ddcutil setvcp xF4 x0090 --i2c-source-addr=x50 --noverify   # HDMI 1
#   ddcutil setvcp xF4 x0091 --i2c-source-addr=x50 --noverify   # HDMI 2
# Most other monitors: ddcutil setvcp 60 0x0F / 0x10
#
# State: /var/lib/kbd_disp/last_detach   (epoch of last local dongle departure)
# Log:   /var/lib/kbd_disp/switch.log

export PATH=/usr/local/sbin:/usr/bin:/bin

STATE_DIR=/var/lib/kbd_disp
LAST_DETACH=$STATE_DIR/last_detach
LOG=$STATE_DIR/switch.log
UNIT=kbd-disp-decide
PUSH_UNIT=kbd-disp-push

[ -r /etc/kbd_disp.conf ] || { echo "kbd_disp_switch: missing /etc/kbd_disp.conf" >&2; exit 1; }
. /etc/kbd_disp.conf

SETTLE=${SETTLE:-2}
SSH_KEY=${SSH_KEY:-/root/.ssh/kbd_disp}
DDC_CMD=${DDC_CMD:-'ddcutil setvcp xF4 %s --i2c-source-addr=x50 --noverify'}

log() {
    if [ -f "$LOG" ]; then
        size=$(wc -c < "$LOG")
        [ "$size" -gt 1048576 ] && mv -f "$LOG" "$LOG.1"
    fi
    echo "$(date '+%Y-%m-%d %H:%M:%S') $*" >> "$LOG"
}

mkdir -p "$STATE_DIR" 2>/dev/null

# ssh helper: non-interactive, bounded, root-run (udev/systemd context)
ssh_peer() {
    # $1 = user@host, $2 = remote command
    [ -n "$1" ] || return 1
    timeout 6 ssh -o BatchMode=yes -o ConnectTimeout=3 \
        -o IdentitiesOnly=yes -o IdentityFile="$SSH_KEY" \
        -o UserKnownHostsFile=/root/.ssh/known_hosts \
        -o StrictHostKeyChecking=accept-new \
        "$1" "$2" 2>/dev/null
    return $?
}

# cancel a pending decision (new attach or a detach supersedes it)
cancel_pending() {
    systemctl stop "$UNIT.timer" "$UNIT.service" 2>/dev/null
}

case "$1" in
    detach)
        now=$(date +%s)
        printf '%s\n' "$now" > "$LAST_DETACH"
        cancel_pending
        if [ -n "$PEER_PUSH" ]; then
            if ssh_peer "$PEER_PUSH" "kbd_disp_peer set $now"; then
                log "detach: pushed departure (t=$now) to $PEER_PUSH"
            else
                log "detach: WARN push to $PEER_PUSH failed (peer file may be stale)"
            fi
        fi
        log "detach: departure recorded (t=$now), no switch"
        exit 0
        ;;
    attach) ;;
    decide) ;;
    push) ;;
    *) echo "usage: $0 {attach|detach|decide|push}" >&2; exit 2 ;;
esac

case "$MY_INPUT" in
    1) VAL=x0090 ;;
    2) VAL=x0091 ;;
    *) log "FATAL: MY_INPUT='$MY_INPUT' (must be 1 or 2)"; exit 1 ;;
esac

if [ "$1" = "attach" ]; then
    cancel_pending
    if systemd-run --no-block --unit="$UNIT" --collect \
        /usr/local/sbin/kbd_disp_switch decide 2>/dev/null; then
        log "attach: scheduled decision in ${SETTLE}s"
    else
        log "attach: WARN systemd-run failed (no decision scheduled)"
    fi
    if [ -n "$PEER_PUSH" ]; then
        systemctl stop "$PUSH_UNIT.service" 2>/dev/null
        systemd-run --no-block --unit="$PUSH_UNIT" --collect \
            /usr/local/sbin/kbd_disp_switch push 2>/dev/null
    fi
    exit 0
fi

if [ "$1" = "push" ]; then
    now=$(date +%s)
    if ssh_peer "$PEER_PUSH" "kbd_disp_peer set $now"; then
        log "push: sent t=$now to $PEER_PUSH"
    else
        log "push: WARN failed (t=$now)"
    fi
    exit 0
fi

# ---- decide (runs as a systemd-run unit) ----
sleep "$SETTLE" || exit 0     # debounce: a detach/duplicate event cancels this unit
my_D=$(cat "$LAST_DETACH" 2>/dev/null)
peer_D=""
if [ -n "$PEER_QUERY" ]; then
    peer_D=$(ssh_peer "$PEER_QUERY" "kbd_disp_peer get")
elif [ -n "$PEER_STATE_FILE" ]; then
    peer_D=$(cat "$PEER_STATE_FILE" 2>/dev/null)
fi
case "$peer_D" in ''|*[!0-9]*) peer_D=0 ;; esac
case "$my_D"   in ''|*[!0-9]*) my_D=0 ;; esac

if [ "$my_D" -gt 0 ] && [ "$peer_D" -gt 0 ] && [ "$my_D" -gt "$peer_D" ]; then
    log "decide: no-op (my departure t=$my_D is newest — round trip/flap, display already on input $MY_INPUT)"
    exit 0
fi
log "decide: real move (peer departure t=${peer_D:-none} newer — or unknown) — switching to input $MY_INPUT"

for i in 1 2 3; do
    cmd=$(printf "$DDC_CMD" "$VAL")
    out=$(sh -c "$cmd" 2>&1)
    rc=$?
    if [ "$rc" -eq 0 ]; then
        log "decide: switch to input $MY_INPUT OK (attempt $i)"
        exit 0
    fi
    log "decide: switch attempt $i FAILED rc=$rc: $out"
    sleep 3
done
log "decide: GAVE UP switching to input $MY_INPUT"
exit 1