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

About | Log | Files | Refs | License

README.md (5246B)


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

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 for how to find the right command for your monitor.

Requirements

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

License

MIT β€” see LICENSE.

# 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](file/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](file/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](file/LICENSE).