- Go 99.3%
- Shell 0.5%
- Makefile 0.2%
Enumeration reads only sysfs, but every command including list opens the device node read-write: picking the HID++ interface means pinging it, and a ping is a write. The README said list needed no write access, which is wrong. Adds the udev rule. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|---|---|---|
| cmd/mysolaar | ||
| docs/superpowers | ||
| internal | ||
| .gitignore | ||
| go.mod | ||
| go.sum | ||
| Makefile | ||
| README.md | ||
| switch | ||
mysolaar
Change the active host on a specific Logitech device from the command line, on Linux and macOS.
Multi-host Logitech peripherals (MX Keys, MX Master, and friends) can pair with up to three computers and switch between them — the Easy-Switch buttons on the device. This does the same thing from a terminal, on both platforms, targeting one named device at a time.
Status: working, lightly used. Verified on macOS against an MX Keys Mini and an MX Master 3S, including a real host switch. The Linux backend compiles and is written against sysfs and hidraw, but has not yet been run on Linux hardware. Design notes:
docs/superpowers/specs/2026-10-02-mysolaar-design.md.
Why this exists
Solaar already does this well, and the design here is derived from reading its source. Two gaps motivated a separate tool:
- Solaar is Python and Linux-first. It does run on macOS — installed via pip
plus
brew install hidapi gtk+3 pygobject3, with no Homebrew formula — but the project documents "limited support for macOS", and there is no single binary to drop on a machine. - Solaar picks the first device that answers when a name is ambiguous
(
lib/solaar/cli/config.py:172-175). For most settings that is untidy. For a host switch it hands the wrong device to another computer, and nothing on this machine can undo it.
Scope is deliberately one job. No GUI, no settings framework, no rules engine, no receiver or pairing support. Solaar is ~40k lines; this targets a few hundred.
Usage
mysolaar list # all devices, current host, safe selector
mysolaar host --device <name> # show host slots for one device
mysolaar host <n> --device <name> # switch that device to slot n (1-based)
mysolaar host <n> --device <name> --dry-run
list prints a selector guaranteed to address each device alone, so the safe
thing to type is always on screen:
NAME HOST HOSTS SELECTOR
MX Keys Mini 1 3 keys
MX Master 3 Mac 2 3 "3 mac"
MX Master 3S 1 3 3s
$ mysolaar host 2 --device keys
MX Keys Mini -> host 2
Exit codes: 0 ok, 1 runtime error, 2 usage error, 3 no device matched,
4 permission denied, 5 device lacks feature 0x1814, 6 ambiguous selector.
Device selection
Devices are addressed by name. Switching the wrong device is the worst thing this tool can do, so resolution never guesses.
Tried in order; the first tier that produces matches decides:
- Exact name, case-insensitive.
- Unique name substring, case-insensitive.
- Serial or product ID, exact — only for two devices of the same model, where names cannot distinguish them.
More than one match in the deciding tier is a hard error. The tool exits without writing and lists candidates with a selector for each:
error: "MX Master 3" matches 2 devices
MX Master 3 Mac use: --device "3 mac"
MX Master 3S use: --device 3s
It never falls through to a later tier to break a tie. --device is required
whenever more than one HID++ device is present.
One physical device exposes several HID interfaces, so interfaces are grouped
into one logical device — by the parent hid device in sysfs on Linux, by serial
number on macOS — then pinged, keeping the one that answers.
Protocol notes
The useful output of this project so far. All of it was traced through Solaar's source; the non-obvious parts are recorded because each one silently breaks a reasonable implementation.
Framing
Short report 0x10 is 7 bytes, long report 0x11 is 20
(lib/logitech_receiver/base.py:90-97):
[report_id, device_index, feature_index, (func | software_id), params...]
func is stored pre-shifted into the high nibble throughout Solaar: function 0
is 0x00, function 1 is 0x10.
Bluetooth devices require long reports
lib/logitech_receiver/device.py:695:
long = self.hidpp_long is True or (
self.hidpp_long is None and (self.bluetooth or self._protocol is not None and self._protocol >= 2.0)
)
Bluetooth transport forces long messages. Every request is a 20-byte 0x11
report even when the payload would fit in a short one. Short reports to these
devices simply do not work.
Bluetooth devices reply from a different device index
Direct devices are addressed as device_index = 0xFF, but replies may carry
0x00. base.py:731 accepts either:
if reply_devnumber == devnumber or reply_devnumber == devnumber ^ 0xFF: # BT device returning 0x00
0xFF ^ 0xFF == 0x00. A matcher that only accepts 0xFF discards every
response.
The software ID is a shared registry
The low nibble of byte 3 identifies the client so cooperative tools can filter
each other out of a shared reply stream. It is fixed, not rotated. Known
occupants, per base.py:897-915:
| ID | Tool |
|---|---|
0x07 |
OpenRGB |
0x0A |
LGSTrayEx |
0x0B |
Solaar |
0x0D |
Logitech G HUB |
0x0F |
Logitech firmware |
mysolaar uses 0x09 — unclaimed, and keeps bit 0x08 set so notifications
(software ID 0) stay distinguishable from replies.
Direct devices inherit the short timeout
base.py:698 selects the timeout by testing devnumber == 0xFF, meaning "this
is the receiver" — but Bluetooth-direct devices are 0xFF, so they get
0.9s rather than the 4s device timeout. Probably accidental upstream.
Reading host names writes to the device
hidpp20.py:2083, inside get_host_names:
if host_names: # update the current host's name if it doesn't match the system name
hostname = socket.gethostname().partition(".")[0]
if host_names[currentHost][1] != hostname:
self.set_host_name(device, hostname, host_names[currentHost][1])
Solaar writes your local hostname into the device's host-name table as a side
effect of reading it, changing what your other machines display. mysolaar reads
names read-only and never calls setHostName.
The whole feature is four requests
ROOT.GetFeature makes a full feature table unnecessary. With software ID
0x09, device index 0xFF, and F as the resolved index for 0x1814:
ping [0x11, 0xFF, 0x00, 0x19, 0x00, 0x00, mark, 0x00 x13]
getFeature [0x11, 0xFF, 0x00, 0x09, 0x18, 0x14, 0x00 x14]
getHostInfo [0x11, 0xFF, F, 0x09, 0x00 x16]
setHost [0x11, 0xFF, F, 0x19, hostIndex, 0x00 x15] <- no reply
mark is a random byte echoed back, used to recognise your own ping reply.
setHost gets no response. The device disconnects as it switches away
(settings_templates.py:1300, no_reply: True). Modelling it as a request with
a swallowed timeout would stall every switch and report real failures as success.
Two related rules carried over: drain the input buffer before every write
(base.py:866 — a stale reply otherwise satisfies the next request), and reset
the timeout clock whenever non-matching traffic arrives, so a busy notification
stream cannot starve a pending request.
Host slots
0-based on the wire, presented 1-based. Note the two features' layouts differ — a confusable pair:
| Feature | function 0 payload |
|---|---|
0x1814 ChangeHost |
[numHosts, currentHost, ...] |
0x1815 HostsInfo |
[capabilityFlags, _, numHosts, currentHost, ...] |
The host setting is never persisted. Solaar annotates it "persisting this setting is harmful" — replaying a saved host at startup would yank the device away from whichever machine is using it. mysolaar writes no state at all.
Platform requirements
| Platform | cgo | Needs |
|---|---|---|
| Linux | CGO_ENABLED=0 |
nothing; cross-compiles freely |
| macOS | CGO_ENABLED=1 |
Xcode command line tools, hidapi |
Linux talks to /dev/hidraw* directly, enumerating via
/sys/class/hidraw/hidraw*/device/uevent and reading each report descriptor
from the world-readable /sys/.../device/report_descriptor. No libudev, no cgo.
Enumeration itself needs no device access, but every command opens the node
read-write, including list: picking the right interface means pinging it,
and a ping is a write. So Linux needs a udev rule or group membership for
/dev/hidraw*, e.g.
# /etc/udev/rules.d/60-mysolaar.rules
KERNEL=="hidraw*", ATTRS{idVendor}=="046d", MODE="0660", TAG+="uaccess"
macOS uses hidapi's IOHIDManager backend through cgo, and must disable hidapi's exclusive open before touching a device. Without it the process seizes the device and your keyboard stops working everywhere else.
The ordering is the part that bites, and it cost real debugging time here:
hid_init() turns exclusivity back on every time it initialises —
hid_darwin_set_open_exclusive(1); /* Backward compatibility */ at
hid_darwin.c:472 — so disabling it once at process start is silently undone by
the first hid.Init(). The symptom is subtle: everything works, but devices the
tool opens disappear for every other application, and a device already held
elsewhere vanishes from enumeration entirely. mysolaar therefore re-asserts the
setting after Init, verifies it with GetOpenExclusive, and refuses to open
anything if it cannot be turned off.
(sstallion/go-hid exposes this directly as hid.SetOpenExclusive(bool); no
cgo shim is needed, contrary to what an earlier draft of the design claimed.)
macOS also requires Input Monitoring permission for the calling process: System Settings → Privacy & Security → Input Monitoring. Without it, enumeration returns nothing at all rather than failing — which is exactly how a connected device appears to be absent.
Coexistence
Logi Options+, Solaar, OpenLogi and this tool all compete for the same HID++ interfaces, and fighting over them is the most common cause of a tool "not seeing" a device. Mitigations here are non-exclusive open on macOS and a distinct software ID. Running Logi Options+ at the same time is still best avoided.
Development
make test # go build ./... plus the unit suite
go test -tags hardware ./... # requires the real devices
make test builds as well as tests on purpose: the suite lives entirely under
internal/, so it passed happily at one point while cmd/mysolaar/main.go was
missing from the repository altogether (a .gitignore pattern of mysolaar
matches the cmd/mysolaar/ directory too — it needs to be /mysolaar).
Everything above the transport layer tests against recorded exchanges in
testdata/fixtures/, so framing, reply matching, feature discovery and device
selection need no hardware. Fixtures carry no serial numbers or host names.
The hardware-tagged suite covers what fixtures cannot: that enumeration finds each physical device once despite multiple interfaces, that a real host switch works, that the two mice stay put while the keyboard switches, and — most importantly — that after mysolaar opens a device, the keyboard still types in another application.
Credit
The protocol knowledge here is Solaar's, read from its source. pwr-Solaar/Solaar, GPL-2.0. If you are on Linux, use Solaar — it is mature and does far more than this.