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:
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:
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:
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:
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:
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:
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 60returns 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:
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 andusb_iddoesn’t run, soATTR{idVendor}/ID_VENDOR_IDare unavailable.
Create the config. On HOST_B (active):
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):
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:
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):
sudo ssh-keygen -t ed25519 -N '' -f /root/.ssh/kbd_disp
On HOST_A (passive), install the whitelisted wrapper:
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:
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:
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
- 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.
- Move HOST_A β HOST_B: β switches back to HDMI 2.
- Round trip on HOST_B: unplug, wait 30β60 s, replug β no flicker (the display never moved, so no write happens).
- Flap drill: quick yank + replug several times β the display must not move.
Check the event trace on either host:
tail -f /var/lib/kbd_disp/switch.log
9. Uninstall
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.
