#3321·Solaar

unpair: --dry-run is silently ignored without --slot and performs a real unpair

Author: rdannenbringCreated Sep 7, 2026Updated Sep 7, 2026

Summary

solaar unpair --dry-run <device> silently performs a real, forced unpair. The --dry-run flag is documented as applying only to --slot, but argparse accepts it alongside a positional device argument, where nothing reads it. The command then prints Unpaired N: ... — output indistinguishable from a genuine run — so there is no way to tell from the terminal that a write happened.

I lost two pairings this way while trying to preview which slots an unpair would clear.

Version

  • Solaar 1.1.20 (Arch solaar 1.1.20-2); code is identical on master as of today
  • Bolt receiver, 046d:C548, three slots paired to the same MX Master 3S

Reproduction

With a receiver that has a device paired in slot 1:

bash
$ solaar unpair --dry-run 1
Unpaired 1: MX Master 3S (MX Master 3S) [B034:47206275]

$ solaar show | grep "paired device"
  Has 2 paired device(s) out of a maximum of 6.   # was 3

The device is really gone.

Cause

In lib/solaar/cli/unpair.py, run() dispatches to _run_slot_unpair() only when --slot is given. The positional-device path below it never consults args.dry_run:

python
def run(receivers, args, find_receiver, find_device):
    assert receivers

    if getattr(args, "slot", None) is not None:
        _run_slot_unpair(receivers, args, find_receiver)
        return

    assert args.device, "unpair requires a device name, or use --slot"

    device_name = args.device.lower()
    dev = next(find_device(receivers, device_name), None)
    if not dev:
        raise Exception(f"no device found matching '{device_name}'")
    ...
    dev.receiver._unpair_device(number, True)  # force an unpair
    print(f"Unpaired {int(number)}: {dev.name} ({codename}) [{wpid}:{serial}]")

_run_slot_unpair() handles the flag correctly:

python
if getattr(args, "dry_run", False):
    print(f"[dry-run] would force-unpair slot {slot} — no register write issued")
    return

So the behavior is arguably as documented — lib/solaar/cli/__init__.py says "with --slot, run all safety checks but do not issue the unpair register write". The defect is that the flag is silently ignored rather than rejected when that precondition is not met, and the operation it fails to suppress is destructive and not undoable without physically re-pairing.

Why this is worse than it looks

--slot is gated to Lightspeed receivers:

python
if rcv.receiver_kind != "lightspeed":
    raise Exception("--slot unpair is currently only supported on Lightspeed receivers ...")

So on Bolt, Unifying, and Nano receivers there is no working dry-run path at all — yet --dry-run is accepted there without complaint, and the only form of the command those users can run is the one that ignores it. A user on a Bolt receiver reading solaar unpair --help sees a --dry-run option and has no indication it cannot apply to them.

Suggested fix

Reject the combination instead of ignoring it:

python
if getattr(args, "dry_run", False) and getattr(args, "slot", None) is None:
    raise Exception("--dry-run is only supported with --slot")

That turns a silent destructive write into an error, and is a two-line change.

Better still, if it is not too invasive: honor --dry-run on the device path too. Everything needed to report the intent is already gathered before the write — number, codename, wpid, serial are read immediately above _unpair_device, so the preview could print the same line prefixed with [dry-run] and return. That would give Bolt/Unifying/Nano users the safety check the flag advertises, rather than only an error explaining they cannot have it.

Happy to open a PR for either version.