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

About | Log | Files | Refs | License

INSTALL.md (5884B)


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 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:

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):

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

  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:

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.

# 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.