No description
  • Go 99.3%
  • Shell 0.5%
  • Makefile 0.2%
Find a file
José Mota c4e09c98c7 docs: correct the Linux permissions claim
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>
2026-10-02 12:38:45 +01:00
cmd/mysolaar fix: address whole-branch review findings 2026-10-02 11:36:02 +01:00
docs/superpowers Add implementation plan 2026-10-02 10:50:19 +01:00
internal fix: address whole-branch review findings 2026-10-02 11:36:02 +01:00
.gitignore fix: address whole-branch review findings 2026-10-02 11:36:02 +01:00
go.mod feat: macOS hidapi transport with non-exclusive open 2026-10-02 11:12:50 +01:00
go.sum feat: macOS hidapi transport with non-exclusive open 2026-10-02 11:12:50 +01:00
Makefile fix: address whole-branch review findings 2026-10-02 11:36:02 +01:00
README.md docs: correct the Linux permissions claim 2026-10-02 12:38:45 +01:00
switch adding switch bash script 2026-10-02 12:38:00 +01:00

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:

  1. Exact name, case-insensitive.
  2. Unique name substring, case-insensitive.
  3. 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.