Making a Logitech mouse follow its keyboard across Macs
Easy-Switch moves the keyboard. The mouse stays behind like it didn't get the memo. So I taught macOS to send the memo.
TL;DR
A tiny root daemon on each Mac polls for the MX Keys keyboard. When it vanishes (you pressed Easy-Switch), the daemon fires one raw HID++ command at the MX Master mouse and it hops to the same computer, about four seconds behind the keyboard. No Logitech software, no network between machines.
It's a macOS port of a clever Windows PowerShell hack, and the port cost far more effort than the idea: macOS guards HID keyboards with two separate gates, and only one of them is documented. Code is open source: omar16100/logi_mx_auto_switch.
The itch
I run one MX Keys and one MX Master 3 across two Macs. The keyboard has three lovely Easy-Switch keys. The mouse hides its switch on the bottom, which means every desk change is: press key 2, start typing, realize the cursor is still on the other machine, flip the mouse over, find the button, resume life.
Logitech's answer is Flow, which wants its software running on both machines and a shared network. The better answer I found is a repo by aguessous: two PowerShell scripts that poll USB for the keyboard and, the moment it disconnects, shove the mouse to the other PC with a raw HID command. Windows only. I wanted it on macOS.
The whole trick is one packet
Logitech devices speak HID++, a small protocol tunneled over vendor HID reports. Feature 0x1814 is literally called ChangeHost. You don't pair anything, you don't negotiate: you write 20 bytes to the mouse's vendor interface and it drops off your machine and onto the next one.
HID++ 2.0 long report, 20 bytes, the only kind BLE speaks:
byte 0 0x11 report ID
byte 1 0xFF device index (BLE-direct convention)
byte 2 feature INDEX (position in the device's table, not the ID)
byte 3 (function << 4) | softwareId
byte 4+ parameters
"Go to host 1" is just: 11 FF 0A 1D 01 00 00 ... and the mouse is gone. Two BLE quirks made this more fun than expected. First, the feature table is firmware-specific, so the code asks the device where ChangeHost lives at runtime instead of hardcoding an index. Second, a successful ChangeHost never answers you: the Bluetooth link is already gone. Success looks exactly like failure until you notice the mouse disappeared from enumeration, so that's precisely what the watcher checks.
My favorite discovery: the keyboard's HostsInfo feature (0x1815) returns the friendly name of every machine it's paired to. The bytes came back and spelled out my other Mac's Bluetooth name. The daemon's config wrote itself.
# ask the keyboard who its host slots belong to (feature 0x1815)
write: 11 FF 0A 3D 01 00 00 ...
read: 11 FF 0A 3D 01 00 <up to 14 UTF-8 Bluetooth-name bytes> ...
# the config wrote itself: slot names map to physical machines macOS makes you earn it
On Windows the original scripts just run. On macOS, opening a HID device that looks like a keyboard is (reasonably!) treated as keylogger behavior. What I expected: grant Input Monitoring in System Settings, done. What actually happened: I granted it, and the open still failed with privilege violation.
The unified log told the real story. TCC approved the request. The kernel then rejected it anyway:
# tccd, for the same open that failed:
ReqResult(Auth Right: Allowed (System Set)) <- TCC said YES
# kernel, one line later:
IOHIDLibUserClient ... privilegedClient : No
IOHIDLibUserClient ... open client not privileged <- kernel said NO
There are two gates. Input Monitoring is the documented one. The second lives inside IOHIDFamily and wants a privileged client, meaning root (or an Apple-private entitlement you can't sign). So the watcher runs as a root LaunchDaemon, and both gates get satisfied. Along the way I also learned that a running tmux server caches its TCC denial until you kill it, that sudo launchctl submit quietly lands your "root" job in the user domain, and that zsh shadows log with a builtin so your forensics silently return nothing. Each of those burned real time I'm donating to you here.
The bug my tests loved
One confession. I wrote the output parser for the HID tool before ever running it against the real device, invented the output format from memory, and wrote unit tests against my invented format. All green. Meanwhile the actual mouse was answering every probe perfectly and the parser threw every answer away: the real tool prints its read marker lowercase, mid-line, and pads zero-byte reads with a fake buffer of zeros.
The fix was small. The lesson is older than me: tests that validate your mock validate nothing. The suite now tests against verbatim captured device output, hex dumps and all.
What it feels like now
Press key 2 on the keyboard. Cursor keeps moving on the old machine for a breath, then the mouse blinks over to the new one, roughly four seconds behind the keyboard in my use. Press key 1 and both come home, because each Mac runs its own daemon and only ever pushes the mouse away. The watcher debounces (BLE devices blip off enumeration when idle), refuses to fire across sleep/wake gaps, and treats a transport timeout as "unknown" rather than "keyboard left", because the one unforgivable failure mode is yanking your mouse away while you're using it.
Get it
MIT licensed, stdlib-only Python plus one vendored HID binary, 32 unit tests that need no hardware, and docs covering the full permission maze so you don't repeat my archaeology: github.com/omar16100/logi_mx_auto_switch. Start with the README, then docs/setup_new_machine.md.