kbd-disp-switch - Unnamed repository; edit this file 'description' to name the repository.

About | Log | Files | Refs | License

commit a18299606a7c62e1c4d12bb31544d02b9fb0be1c
Author: Bakar Chargeishvili <bakar@bcharge.de>
Date:   Tue, 18 Aug 2026 17:07:03 +0200

Initial commit

Diffstat:
A99-kbd-disp.rules | 12++++++++++++
AINSTALL.md | 203+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ALICENSE | 21+++++++++++++++++++++
AREADME.md | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Akbd_disp.conf.example | 34++++++++++++++++++++++++++++++++++
Akbd_disp_peer | 29+++++++++++++++++++++++++++++
Akbd_disp_switch | 174+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 592 insertions(+), 0 deletions(-)

diff --git a/99-kbd-disp.rules b/99-kbd-disp.rules @@ -0,0 +1,12 @@ +# 99-kbd-disp.rules β€” shared monitor follows the keyboard dongle +# +# Replace 3151:4015 with YOUR device's USB IDs (see `lsusb`). +# The dongle may carry a mouse + hub; the rule matches the device itself, +# so one event per plug. +# +# NOTE: remove events lack ID_VENDOR_ID / ATTR{idVendor} (the usb_id builtin +# does not run on remove and the sysfs attributes are already gone) β€” match +# on the kernel's PRODUCT env var instead. Bare DEVTYPE== is not a valid +# udev key; use ENV{DEVTYPE}. +SUBSYSTEM=="usb", ATTR{idVendor}=="3151", ATTR{idProduct}=="4015", ACTION=="add", RUN+="/usr/local/sbin/kbd_disp_switch attach" +SUBSYSTEM=="usb", ENV{DEVTYPE}=="usb_device", ENV{PRODUCT}=="3151/4015/*", ACTION=="remove", RUN+="/usr/local/sbin/kbd_disp_switch detach" diff --git a/INSTALL.md b/INSTALL.md @@ -0,0 +1,203 @@ +# Installation + +Step-by-step guide. Replace the placeholders: + +| Placeholder | Meaning | +|-------------|---------| +| `HOST_A` | the host wired to monitor **HDMI 1** | +| `HOST_B` | the host wired to monitor **HDMI 2** | +| `USER` | the unprivileged user on the passive host (for ssh) | +| `VID:PID` | your dongle's USB IDs (e.g. `3151:4015`) | + +Both hosts run the same script; only the config differs. The hosts are +asymmetric: **HOST_B is the active host** (it can ssh to HOST_A), **HOST_A is +the passive host** (it only needs sshd). If your network allows it the other +way, just swap the roles. + +--- + +## 1. Prerequisites + +On **both** hosts: + +```sh +sudo apt install ddcutil # Debian/Ubuntu (Arch: sudo pacman -S ddcutil) +``` + +On the **passive** host (HOST_A): an sshd that accepts key auth. + +## 2. Find your dongle's USB IDs + +Plug the dongle in and run: + +```sh +lsusb +``` + +Find the keyboard receiver, e.g. `Bus 003 Device 004: ID 3151:4015 ROYUAN +...`. Note the `VID:PID` (`3151:4015` in the example) β€” you'll need it for +the udev rule. + +## 3. Find your monitor's DDC bus + +On **both** hosts: + +```sh +ddcutil detect +``` + +Note the display's I2C bus (e.g. `/dev/i2c-13`). The script uses +`ddcutil`'s auto-detection, so no bus config is needed β€” but verify the +monitor is reachable: + +```sh +ddcutil getvcp 10 # brightness β€” should return a value +``` + +## 4. Find the right input-switching command + +This is the only hardware-specific part. Try the standard VCP 60 first: + +```sh +ddcutil setvcp 60 0x0F # HDMI 1 (0x10 = HDMI 2, 0x11 = DVI, 0x12 = DP) +``` + +If that returns `Invalid value (sl=0x00)` (common on recent **LG** monitors), +use the manufacturer side-channel instead: + +```sh +ddcutil setvcp xF4 x0090 --i2c-source-addr=x50 --noverify # HDMI 1 +ddcutil setvcp xF4 x0091 --i2c-source-addr=x50 --noverify # HDMI 2 +``` + +Test **both** directions and confirm the monitor actually switches. The +`--noverify` flag avoids a verification read-back (which some monitors +answer with garbage); the benign stderr line "Both --verify and --noverify +specified" can be ignored. + +> **Note:** many monitors cannot report their *current* input (`getvcp 60` +> returns garbage). This script never reads the current input β€” it only +> writes when the state actually changed, so a no-op write (and the 2–3 s +> blackout it causes) never happens. + +If your monitor needs a different command, set `DDC_CMD` in +`/etc/kbd_disp.conf` (see the config example). + +## 5. Install the script, config and udev rule + +On **both** hosts: + +```sh +sudo install -m 755 kbd_disp_switch /usr/local/sbin/kbd_disp_switch +sudo install -m 644 99-kbd-disp.rules /etc/udev/rules.d/99-kbd-disp.rules +``` + +Edit the udev rule to use **your** `VID:PID` (both the `ATTR{idVendor}`/ +`ATTR{idProduct}` match and the `ENV{PRODUCT}` match): + +``` +SUBSYSTEM=="usb", ATTR{idVendor}=="3151", ATTR{idProduct}=="4015", ACTION=="add", RUN+="/usr/local/sbin/kbd_disp_switch attach" +SUBSYSTEM=="usb", ENV{DEVTYPE}=="usb_device", ENV{PRODUCT}=="3151/4015/*", ACTION=="remove", RUN+="/usr/local/sbin/kbd_disp_switch detach" +``` + +> The remove rule **must** match on `ENV{PRODUCT}` β€” on remove events the +> sysfs attributes are gone and `usb_id` doesn't run, so +> `ATTR{idVendor}`/`ID_VENDOR_ID` are unavailable. + +Create the config. On **HOST_B** (active): + +```sh +sudo tee /etc/kbd_disp.conf <<'EOF' +MY_INPUT=2 +SETTLE=2 +PEER_QUERY=USER@HOST_A +PEER_PUSH=USER@HOST_A +SSH_KEY=/root/.ssh/kbd_disp +EOF +``` + +On **HOST_A** (passive): + +```sh +sudo tee /etc/kbd_disp.conf <<'EOF' +MY_INPUT=1 +SETTLE=2 +PEER_STATE_FILE=/var/lib/kbd_disp/peer_detach +EOF +``` + +Reload udev and verify the rules parse: + +```sh +sudo udevadm control --reload-rules +sudo udevadm verify /etc/udev/rules.d/99-kbd-disp.rules +``` + +## 6. Set up the peer ssh (active β†’ passive) + +On **HOST_B** (active), generate a dedicated passphrase-less key for the +automation (udev runs as root and must not require interaction): + +```sh +sudo ssh-keygen -t ed25519 -N '' -f /root/.ssh/kbd_disp +``` + +On **HOST_A** (passive), install the whitelisted wrapper: + +```sh +sudo install -m 755 kbd_disp_peer /usr/local/sbin/kbd_disp_peer +``` + +Append the automation public key to `USER`'s `~/.ssh/authorized_keys` on +HOST_A, restricted to the two wrapper operations (least privilege): + +``` +command="/usr/local/sbin/kbd_disp_peer",no-pty,no-agent-forwarding,no-X11-forwarding,no-port-forwarding ssh-ed25519 AAAA... kbd-disp-automation +``` + +Back on HOST_B, accept the host key and test: + +```sh +sudo sh -c 'ssh-keyscan -t ed25519 HOST_A >> /root/.ssh/known_hosts' +sudo ssh -i /root/.ssh/kbd_disp USER@HOST_A kbd_disp_peer get +``` + +The `get` should print a number (or nothing, if the file doesn't exist yet). + +## 7. Seed the state files + +On **HOST_A** (passive), seed both state files with a sentinel so that round +trips before the peer ever hosts the dongle are correctly detected as no-ops: + +```sh +echo 1 | sudo tee /var/lib/kbd_disp/last_detach >/dev/null +echo 1 | sudo tee /var/lib/kbd_disp/peer_detach >/dev/null +``` + +## 8. Test + +1. **Move HOST_B β†’ HOST_A**: unplug the dongle from HOST_B, plug it into + HOST_A β†’ the monitor switches to HDMI 1 within a few seconds. +2. **Move HOST_A β†’ HOST_B**: β†’ switches back to HDMI 2. +3. **Round trip on HOST_B**: unplug, wait 30–60 s, replug β†’ **no flicker** + (the display never moved, so no write happens). +4. **Flap drill**: quick yank + replug several times β†’ the display must not + move. + +Check the event trace on either host: + +```sh +tail -f /var/lib/kbd_disp/switch.log +``` + +## 9. Uninstall + +```sh +sudo rm -f /usr/local/sbin/kbd_disp_switch /usr/local/sbin/kbd_disp_peer \ + /etc/kbd_disp.conf /etc/udev/rules.d/99-kbd-disp.rules +sudo rm -rf /var/lib/kbd_disp +sudo udevadm control --reload-rules +``` + +Remove the automation key from `authorized_keys` on the passive host and +delete `/root/.ssh/kbd_disp` on the active host. diff --git a/LICENSE b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 kbd-disp-switch contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md @@ -0,0 +1,119 @@ +# kbd-disp-switch + +Two PCs share one monitor and one USB keyboard/mouse dongle. Plug the dongle +into a PC and the monitor automatically switches to that PC's HDMI input. +Unplug it and the display stays put ("display follows arrivals only"). + +Built around udev + `ddcutil`, with a small ssh side-channel between the two +hosts so that unplug/replug on the *same* host never causes a pointless +monitor re-handshake (a 2–3 s black flicker on many monitors). + +## How it works + +``` + +------------------+ +------------------+ + | host A | | host B | + | HDMI-1 | | HDMI-2 | + | | | | + | udev rule | | udev rule | + | kbd_disp_switch | | kbd_disp_switch | + | (active) | | (passive) | + +--------+---------+ +--------+---------+ + | | + | ssh (query + push) | reads pushed state + +------------------------------+ +``` + +* **udev** fires on dongle **add** (`attach`) and **remove** (`detach`). +* On `attach`, a decision is scheduled (a `systemd-run` transient unit that + sleeps `SETTLE` seconds, then decides). A quick unplug/replug cancels the + pending decision. +* On `detach`, the departure time (epoch) is recorded locally and pushed to + the peer over ssh. +* The decision is **event-ordering, not a time window**: + + ``` + no-op iff my_D > 0 AND peer_D > 0 AND my_D > peer_D + ``` + + where `my_D` = this host's last departure, `peer_D` = the peer's last + departure. If *my* departure is the newest, the dongle just came straight + back (round trip / flap) β€” the display never moved, so no write. Otherwise + it's a real move β€” switch to `MY_INPUT`. Unknown peer state resolves to + "switch" (safe default). + +### Why the ssh side-channel? + +"Unplug β†’ replug" is locally identical for (a) a round trip on the same host +(no-op needed) and (b) a round trip *through the peer* (switch needed β€” the +peer's attach already moved the display). Only the peer knows which happened, +so the two hosts exchange departure timestamps. The hosts are asymmetric: +one is **active** (queries the peer over ssh), the other **passive** (reads a +state file the active host pushes). This avoids needing sshd on both hosts. + +### Why systemd-run? + +udev kills leftover processes of `RUN` programs (cgroup kill β€” even `setsid` +doesn't help), so a backgrounded decision job simply dies. A transient +`systemd-run` unit survives and is cancellable via `systemctl stop`. The +decision unit sleeps `SETTLE` internally because `systemd-run --on-active` +timers proved unreliable (fired 6–60 s late in practice). + +### Why a custom DDC command? + +Standard input switching (`ddcutil setvcp 60 ...`) is broken on recent LG +monitors (returns "Invalid value"). The working method is the manufacturer +side-channel: `ddcutil setvcp xF4 x0090 --i2c-source-addr=x50 --noverify` +(HDMI 1) / `x0091` (HDMI 2). See [INSTALL.md](INSTALL.md) for how to find the +right command for your monitor. + +## Requirements + +* Linux with systemd (udev) on both hosts +* `ddcutil` on both hosts +* `ssh` client on the active host, `sshd` on the passive host +* A monitor that supports DDC/CI input switching + +## Install + +See [INSTALL.md](INSTALL.md) β€” step by step, including finding your device +IDs, your monitor's DDC bus, and the right input-switching command. + +## Files + +| File | Purpose | +|------|---------| +| `kbd_disp_switch` | main script (install to `/usr/local/sbin/`) | +| `kbd_disp_peer` | whitelisted ssh wrapper for the passive host | +| `kbd_disp.conf.example` | per-host config template (`/etc/kbd_disp.conf`) | +| `99-kbd-disp.rules` | udev rule (replace the USB IDs) | + +## Configuration reference + +| Variable | Meaning | +|----------|---------| +| `MY_INPUT` | `1` β†’ monitor HDMI 1, `2` β†’ HDMI 2 | +| `SETTLE` | debounce seconds before the decision (default 2) | +| `PEER_QUERY` | active host: `user@peer` for querying the peer's departure | +| `PEER_PUSH` | active host: `user@peer` for pushing our departure | +| `SSH_KEY` | automation key path (default `/root/.ssh/kbd_disp`) | +| `PEER_STATE_FILE` | passive host: local file the active host's pushes land in | +| `DDC_CMD` | DDC write command; `%s` = input value (default: LG side-channel) | + +## Troubleshooting + +* **The screen never switches on attach.** Check the log + (`/var/lib/kbd_disp/switch.log`) and that the udev rule matches: + `udevadm test --action=add /sys/bus/usb/devices/<dev>`. +* **Detaches never fire.** Remove events lack `ID_VENDOR_ID`/`ATTR{idVendor}` + β€” the rule must match `ENV{PRODUCT}=="<vid>/<pid>/*"` (see the rule file). +* **The decision job dies.** udev kills backgrounded jobs; the decision must + run as a `systemd-run` unit. +* **`getvcp 60` fails on an LG.** Expected β€” use the side-channel command. +* **The monitor cannot report its current input.** Many monitors (including + the LG this was built against) return garbage for `getvcp 60`; the script + therefore never reads the current input β€” it only writes on state change. + +## License + +MIT β€” see [LICENSE](LICENSE). diff --git a/kbd_disp.conf.example b/kbd_disp.conf.example @@ -0,0 +1,34 @@ +# /etc/kbd_disp.conf β€” per-host configuration for kbd_disp_switch +# +# Copy this file to /etc/kbd_disp.conf on EACH host and adjust. +# The two hosts are asymmetric: one is the "active" host (queries the peer +# over ssh), the other is "passive" (reads a state file pushed by the peer). +# See README.md "Architecture" for why. + +# Which monitor input this host is wired to. +# 1 -> x0090 (monitor HDMI 1) 2 -> x0091 (monitor HDMI 2) +MY_INPUT=2 + +# Seconds to wait after a dongle attach before deciding. Gives a quick +# unplug/replug (flap) time to cancel the pending decision. +SETTLE=2 + +# --- Active host (the one that can ssh to the peer) --- +# ssh target for querying the peer's last departure time. +PEER_QUERY=user@peer-host +# ssh target for pushing our departure time to the peer. +PEER_PUSH=user@peer-host +# Dedicated passphrase-less key for the automation (udev runs as root and +# must not require interaction). Restrict it on the peer with command=... +SSH_KEY=/root/.ssh/kbd_disp + +# --- Passive host (leave these commented out) --- +# Local file that the active host's pushes land in (written by kbd_disp_peer). +#PEER_STATE_FILE=/var/lib/kbd_disp/peer_detach + +# --- Optional: override the DDC write command --- +# %s is replaced with the input value (x0090 / x0091 for MY_INPUT 1 / 2). +# Default (LG side-channel, see README): +#DDC_CMD='ddcutil setvcp xF4 %s --i2c-source-addr=x50 --noverify' +# Most other monitors (standard VCP 60): +#DDC_CMD='ddcutil setvcp 60 %s --noverify' diff --git a/kbd_disp_peer b/kbd_disp_peer @@ -0,0 +1,29 @@ +#!/bin/sh +# kbd_disp_peer β€” whitelisted peer operations for the kbd_disp automation. +# +# Installed on the PASSIVE host. Invoked via ssh with a command= restriction +# in authorized_keys, so the automation key can run ONLY these two operations +# (least privilege). SSH_ORIGINAL_COMMAND holds the full command string the +# client sent (not just the arguments). +# +# get -> print the peer's last dongle departure time (epoch) +# set <n> -> store the peer's departure time in peer_detach +# +# State files live in /var/lib/kbd_disp/: +# last_detach this host's own last departure (written by kbd_disp_switch) +# peer_detach the peer's last departure (written by this wrapper) +case "$SSH_ORIGINAL_COMMAND" in + *" get") + cat /var/lib/kbd_disp/last_detach 2>/dev/null + ;; + *" set "*) + val=${SSH_ORIGINAL_COMMAND##* set } + case "$val" in + *[!0-9]*) exit 1 ;; + esac + printf '%s\n' "$val" > /var/lib/kbd_disp/peer_detach + ;; + *) + exit 1 + ;; +esac diff --git a/kbd_disp_switch b/kbd_disp_switch @@ -0,0 +1,174 @@ +#!/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