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 (asystemd-runtransient unit that sleepsSETTLEseconds, 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_Dwhere
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 toMY_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 for how to find the
right command for your monitor.
Requirements
- Linux with systemd (udev) on both hosts
ddcutilon both hostssshclient on the active host,sshdon the passive host- A monitor that supports DDC/CI input switching
Install
See 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 matchENV{PRODUCT}=="<vid>/<pid>/*"(see the rule file). - The decision job dies. udev kills backgrounded jobs; the decision must
run as a
systemd-rununit. getvcp 60fails 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.
