Prelude
My desk hosts two computers but only one monitor. There is a Raspberry Pi running my window manager setup and a desktop PC, both wired to the same display β the Pi on HDMI 2, the PC on HDMI 1. And there is exactly one wireless keyboard and mouse, connected through a single USB dongle that I physically move from one machine to the other depending on which one I want to operate.
The ritual was: plug the dongle in, then reach for the monitor's OSD buttons and manually switch the input. Every. Single. Time. The monitor is the kind of monitor where you hold down a button, wait for a menu, navigate, press again, hope. It works, in the sense that it eventually does what you asked, and it fails, in the sense that it makes you pay for it with your time and dignity every single time. After a while I decided to end this tragedy.
The goal, stated simply: the display follows the dongle. Plug it into a machine and the monitor switches to that machine's HDMI input. Unplug it and leave β the display stays put. No button pressing, no menu, no ritual.
The Naive Version
The first version was embarrassingly straightforward. A udev rule matching
the dongle (a ROYUAN receiver, vendor 3151, product 4015), a shell script
that calls ddcutil to switch the monitor's input, plus a 30-second window
during which the script refuses to do anything, to swallow the occasional
USB blip. ddcutil was happy to write the input, and for a while everything
worked. Move the dongle, screen follows. This is great, I thought. Done.
Of course it wasn't done. It is never done. The universe does not reward the comfortable; it waits for them.
The First Mystery: the Flicker
The thing that kept nagging me was the black flicker. Unplug the dongle from a machine and plug it back in β the monitor goes black for two to three seconds, then comes back. Every time. Even if nothing actually changed, even if the display was already showing the right machine. The monitor does not care whether you meant something by the write. It simply reacts: a write is a write, and every write is a fresh handshake, and every handshake is a blackout. The hardware is indifferent to your intentions.
It took me a while to understand what was happening. My script was happily rewriting the input on every attach, even when the monitor was already on that input. And the LG monitor treats every DDC input write as a new handshake: it re-negotiates the link, blanks for a couple of seconds and comes back. A no-op write causes a full blackout. So the very first rule of this project became:
Never write to the monitor unless the state actually changed.
A fine principle. It assumes you can know the state. You cannot.
The Monitor Is Lying to You
ddcutil getvcp 60 (the standard "current input source" query) returns
Invalid value (sl=0x00). Not just on my machine β on both machines, on the
main channel and the side channel. The monitor simply refuses to answer.
I dug into the ddcutil wiki and found the reason: recent LG monitors broke
the standard input-switching command (setvcp 60 is just as dead as the
read), and the working method is a manufacturer side-channel:
ddcutil setvcp xF4 x0090 --i2c-source-addr=x50 --noverify # HDMI 1
ddcutil setvcp xF4 x0091 --i2c-source-addr=x50 --noverify # HDMI 2
That works, both directions. (It also prints a confusing Both --verify and --noverify specified to stderr, which looks like an error and isn't β the
command exits 0 and the input switches. A nice little red herring for the
first hour of debugging.) The brightness query works fine, so the DDC bus
itself is healthy β the monitor just doesn't want to talk about inputs.
And the reading side is completely hopeless. I tried every angle:
getvcp 60βInvalid value (sl=0x00).- The side channel's read (
getvcp xF4via--i2c-source-addr=x50) β always returnssl=0x07, regardless of which input is active. A constant. - The hot-plug detect signal and the DRM connector status both say
connectedon both hosts, no matter which input the monitor is actually showing. The Pi's HDMI is "connected" even when the monitor is staring at the desktop.
Three independent channels, three lies. The monitor does not malfunction; it simply refuses to be known. There is no bug to report, no firmware to update, no ticket to file. The hardware is as it is, and it will not tell you what it is doing. "Just check the current input" is not a thing on this hardware. So the design had to work arrival-driven: keep state myself, remember what happened, and only ever write when my own bookkeeping says a write is due. Trust nothing the monitor says. Trust only what you have recorded.
How Often Does the Dongle Actually Flap?
Before designing the debounce, I wanted to know what I was up against.
Fifteen days of dmesg told the story: some genuine unplug events when I was
working on my desktop PC (durations ranging from 66 minutes to 89 hours) and
exactly two "flaps" β USB events where the dongle disappears and reappears
almost immediately. One was a 0.3-second presence-bounce (the dongle never
really left, the port just hiccuped); the other was a 13.2-second absence.
So the debounce needed to swallow sub-second blips and also the occasional
multi-second disconnect, without penalising a genuine move that takes
however long it takes to walk the cable across the desk.
0.3 seconds. A third of a second of absence is enough to trigger a full monitor blackout. The hardware does not distinguish between "the user meant to unplug" and "the USB port blinked." It does not care. It just reacts.
The original 30-second window was a guess. It worked for the 0.3-second blip but was hopeless for the 13.2-second one, and β worse β it fundamentally cannot distinguish "unplug, wait, replug on the same host" (no-op) from "unplug, walk to the other machine, plug in, walk back, plug in here" (switch). More on that later.
udev Is Not Your Friend
With the "don't write unless needed" rule in place, the script needed to know two things: when the dongle arrives (switch β maybe) and when it leaves (remember the departure time). Two bugs later I had learned more about udev than I ever wanted to.
Bug one: detach events never fired. My remove rule matched on
ATTR{idVendor}=="3151", same as the add rule. It worked for "add", so
why not for "remove"? Because on remove events, the sysfs attributes are
already gone, and the usb_id builtin doesn't even run on remove, so the
vendor/product environment variables are missing too. The rule silently
never matched. No error, no warning β just silence. The system ate the
event and said nothing, as though it had never happened.
The consequence was subtle and nasty: since detaches never fired, the
departure time was never recorded, and every attach looked like a
first-ever arrival β so the script always switched, and the flicker was
back with a vengeance. I only caught it because the log showed attach events
with no matching detach events, ever. The fix is to match the remove event
on the kernel's PRODUCT environment variable, which is one of the few
things that survives the sysfs teardown:
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"
(Oh, and if you're tempted to match on DEVTYPE=="usb_device" directly β
don't. Bare DEVTYPE== is not a valid udev key. It has to be
ENV{DEVTYPE}. udevadm verify will tell you, but only if you ask.
Bug two: my decision job kept dying. The script schedules the actual switch decision a couple of seconds after the attach, so that a quick unplug/replug (a "flap") can cancel it. I started it as a background job β and it died. Every time. I found this out the hard way: some attaches silently did nothing, the screen just stayed on the wrong machine, and the log showed "scheduled decision" but no "decide" line after it. The process was there, and then it wasn't, and nothing in the log explained why.
So I tried setsid β detaches the process from the process group, surely
that helps? It died too. udev does not just forget about leftover processes
of its RUN programs; it cgroup-kills them. When the RUN program exits,
systemd-udevd tears down the entire cgroup, and everything in it dies β
process group or not. You spawn a child, you give it a task, you walk away,
and the system kills it behind your back. There is no due process, only
cgroup teardown. The only thing that survives is a transient systemd
unit, started with systemd-run, which lives in its own cgroup and is also
conveniently cancellable via systemctl stop. That was a satisfying
eureka moment after an hour of "why is my background job disappearing?"
And while I was at it I found that systemd-run --on-active timers are
fantastically imprecise. I created a timer with --on-active=2s, waited
four seconds β nothing. Measured it: some fired 6 seconds late, some 60.
The default AccuracySec is a whole minute. So the decision unit just
sleeps internally instead of relying on a timer to wake it up. udev giveth,
udev taketh away, and systemd giveth imprecisely.
The Void of Certainty: Round-Trip Ambiguity
"Everything we hear is an opinion, not a fact. Everything we see is a perspective, not the truth." β Marcus Aurelius
With both bugs fixed, the flicker was mostly gone. But there was one case that no amount of time windows could solve, and it is the actual intellectual heart of this project: the realization that any host, in isolation, is fundamentally blind.
Imagine the dongle sitting in the Pi. I unplug it, wait a bit, plug it back into the Pi. The display never moved; a switch would be a pointless blackout. No-op. Now imagine the same sequence β unplug from the Pi, wait, plug into the Pi β but in between, I plugged it into the desktop, the desktop's attach handler dutifully switched the monitor to HDMI 1, and only then did I come back to the Pi. From the Pi's point of view, the two scenarios are locally identical: a departure, then an arrival. But the second one requires switching back.
A time window cannot tell these apart β the "wait" can be arbitrarily long in both cases. You could wait 10 seconds before replugging on the same host, or you could spend 10 seconds walking the cable to the other machine and back. In the absence of a truthful monitor, the host is trapped in a solipsistic loop, unable to know if the world changed while it was blind. It remembers that it lost the dongle. It sees the dongle return. Was anything else different? It cannot tell. The monitor will not say. The kernel will not say. There is no oracle, no ground truth, no way to verify the contents of reality from a single vantage point.
Only one participant can break the cycle: the other machine.
So the two hosts started talking.
On detach, the departure time (an epoch timestamp) is pushed over ssh to the peer; on attach, the peer's last departure is queried. The decision is pure event ordering, no clock, no window:
# no-op iff my departure is newer than the peer's last departure
if [ "$my_D" -gt 0 ] && [ "$peer_D" -gt 0 ] && [ "$my_D" -gt "$peer_D" ]; then
noop # the dongle came straight back -- round trip / flap
else
switch # the peer's departure is newer, or the peer is unknown
fi
If my departure is the newest event, the dongle just came straight back β round trip, no-op. If the peer's departure is newer, the dongle visited the peer β switch. Unknown peer state (ssh failed, peer never hosted the dongle) resolves to "switch", the safe default: one redundant flicker in a rare failure beats a display stuck on the wrong machine. When you cannot know, you act. Inaction is its own failure.
I tested this decision matrix in a sandbox with a fake ddcutil that just
logged its calls to a file β six scenarios, from "first-ever attach"
through "round trip on me" to "genuine move from the peer" β and watched
which ones wrote to the fake monitor and which didn't. All six were
correct. (I also accidentally did real DDC writes during sandbox testing,
because the script's export PATH=... line clobbered my fake ddcutil
out of the PATH, so the real one ran and the monitor got a few surprise
writes. The dongle was on the Pi, the monitor was already on the Pi, so
the writes were redundant β but the display probably blinked at me a
couple of times for no reason. Testing is humbling. The system does not
care about your sandbox; it does what the code says, not what you meant.)
The protocol is deliberately asymmetric: only one direction of ssh exists
(the Pi is the active side, the desktop is passive and just reads a pushed
state file), so only one machine needs an ssh daemon. The automation runs
as root from udev, so it uses its own dedicated passphrase-less key,
restricted on the passive side to exactly two operations β get and set
a timestamp β via a command= wrapper in authorized_keys. Least
privilege, because a key that can only read and write one integer is a
key that can't be abused for much.
That wrapper had its own little gotcha: SSH_ORIGINAL_COMMAND holds the
full command string the client sent (kbd_disp_peer get), not just the
arguments (get). My first version matched on case "get" β which never
matched. It took a set -x trace to see that the variable was the whole
command, not the argument. The fix: match with *" get" and *" set "*.
One more trick: seed the passive host's state file with 1 β epoch second
one, 1970-01-01 00:00:01 β so that round trips before the peer has ever
hosted the dongle are also recognized as no-ops instead of causing a
first-time flicker. A one-byte sentinel that makes the first session work
exactly like the hundredth. You pretend the peer was always there, that
the timeline extends back before you started watching, and the logic
works. A useful lie, like most foundations.
But all of this β the timestamps, the ssh tunnel, the sentinel, the event-ordering logic β rests on a single assumption: that the two machines can talk. Pull the network cable, or let the switch fail, or move one machine to a different VLAN, and the entire protocol collapses. The host pushes its departure into silence. It queries the peer and hears nothing back. The unknown resolves to "switch", the safe default, which means: every replug is a flicker. Every round trip is a blackout.
The two machines built a shared reality out of two integers and an ssh connection, and that reality is suspended on a network cable β the dongle does not care, the monitor does not care, nor should they. One more reminder from universe: communication is not a feature you add; it is the ground everything else stands on. Remove it, and each host falls back into its own loop, reacting to everything because it cannot know what matters. The monitor lies, the kernel is silent, and the other machine β the only witness β is gone. You are alone with a dongle and a black screen, and the OSD button is still there.
The Silence of the Hardware
To verify that the desktop's HDMI output works as a visual success indicator, I had another PC connected to HDMI 1 and powered on. So the monitor was showing the desktop's tty1, but the keyboard dongle was on the Pi. I was blindly typing to the Pi while staring at the desktop's login prompt β the keyboard events go to whichever machine the dongle is plugged into, regardless of what the monitor is showing. You type into the void, the Pi receives it, the monitor shows you something completely different, and somehow this is a valid test of the switching logic. It worked. But if you can get the job done without looking at the screen, do you even need the screen?
We spent weeks building a side-channel protocol between two machines so they could quietly hand off control of a physical display without ever asking the monitor's permission. The hardware lies by omission; udev cgroup-kills our processes on a whim; a 0.3-second presence-bounce ruins your afternoon. None of this matters to the hardware. The hardware does not know we built a protocol around it. The monitor does not know it has a side-channel. The dongle does not know it is being tracked. Everything we built exists in the gaps between things that were never designed to cooperate.
The point was never the HDMI handshake. The point was taking something broken β a vendor standard that stopped working, a kernel subsystem that swallows errors silently β and quietly replacing it with three files, so that later generations will reach for the OSD buttons, find nothing there, and have no choice but to press one button instead of zero.
Epilogue
The system has been running for a while now. Move the dongle, the screen follows within a couple of seconds. Yank and replug as fast as you want β nothing happens, the display doesn't even blink. The OSD buttons have been retired. The ritual is over.
In the end, we didn't fix anything. The monitor still lies. The DDC bus
still runs through an undocumented side-channel that no one at LG will
acknowledge. udev still cgroup-kills anything that isn't a systemd unit.
The getvcp command still returns 0x07 and means nothing. We did not
repair the hardware; we built a small, fragile fiction around it β three
files, a state directory, two integers in /var/lib/kbd_disp/ β and the
fiction holds, for now, because the hardware has not yet changed its mind.
And one day it will. A firmware update will move the side-channel. The
dongle will report a new vendor ID. The Pi's kernel will rename /dev/i2c-13
to /dev/i2c-14, and the script will silently fail, and the screen will
stay on the wrong machine, and I will reach for the OSD buttons and
remember that they are still there, that they were always there, that we
only ever borrowed convenience from a system that never promised to keep
it.
The whole thing is a 200-line shell script, a udev rule, and a 20-line ssh wrapper. The most complicated part turned out not to be the switching, but the not switching: knowing when to keep your hands off the monitor. That required the monitor to stop lying, udev to stop killing my jobs, and two machines to agree on a timeline β all for the privilege of pressing zero buttons. A lot of effort for nothing. But then, what isn't.
Everything is on git.bcharge.de/kbd-disp-switch, including installation instructions and the full story of the gotchas (remove-event matching, cgroup-killed jobs, the LG side-channel, the sentinel trick). If you have two computers and one monitor, it might save you some button pressing. And if it doesn't β well, the buttons are still there. They were always there.