commit a18299606a7c62e1c4d12bb31544d02b9fb0be1c
Author: Bakar Chargeishvili <bakar@bcharge.de>
Date: Tue, 18 Aug 2026 17:07:03 +0200
Initial commit
Diffstat:
| A | 99-kbd-disp.rules | | | 12 | ++++++++++++ |
| A | INSTALL.md | | | 203 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | LICENSE | | | 21 | +++++++++++++++++++++ |
| A | README.md | | | 119 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | kbd_disp.conf.example | | | 34 | ++++++++++++++++++++++++++++++++++ |
| A | kbd_disp_peer | | | 29 | +++++++++++++++++++++++++++++ |
| A | kbd_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