Last active
April 1, 2025 09:07
-
-
Save fopina/71345e937195d88f98899420f147d8b5 to your computer and use it in GitHub Desktop.
Script to pair/unpair magic mouse and keyboard
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
""" | |
Magic Switch | |
Script to pair/unpair magic mouse and keyboard | |
Usage: | |
* Save somewhere in $PATH as `magic-switch` | |
* Whenever switching magic devices between laptops: | |
* Run `magic-switch off` on the currently connected one | |
* Then `magic-switch on` in the new one | |
""" | |
import argparse | |
import subprocess | |
import urllib.request | |
import sys | |
from pathlib import Path | |
from time import sleep | |
GIST_URL = "https://gist.githubusercontent.com/fopina/71345e937195d88f98899420f147d8b5/raw/magic_switch.py" | |
CONFIG_DIR = Path.home() / ".config" / "magic-switch" | |
CONFIG_FILE = CONFIG_DIR / "devices.txt" | |
def run_command(command): | |
"""Runs a shell command and prints output.""" | |
try: | |
result = subprocess.run(command, capture_output=True, text=True, check=True).stdout.strip() | |
if result: | |
print(result) | |
return 0 | |
except subprocess.CalledProcessError as e: | |
print(f"Error: {e.stderr.strip()}") | |
return e.returncode | |
def print_notify(message, notify=False): | |
print(message) | |
if notify: | |
run_command(['osascript', '-e', f'display notification "{message}" with title "magic-switch"']) | |
def get_device_ids(): | |
"""Reads device IDs from the config file.""" | |
if not CONFIG_FILE.exists(): | |
print(f"Error: Device list not found. Create {CONFIG_FILE} and add one device ID per line.") | |
sys.exit(1) | |
return [line.strip() for line in CONFIG_FILE.read_text().splitlines() if line.strip() and line.strip()[0] != '#'] | |
def bluetooth_on(retries=0, force_connect=False, notify=False): | |
"""Unpairs and then pairs all devices from the config file.""" | |
devices = get_device_ids() | |
for device in devices: | |
print(f"Unpairing {device}...") | |
run_command(["blueutil", "--unpair", device]) | |
for device in devices: | |
for _t in range(retries + 1): | |
print(f"Pairing {device}...") | |
c = run_command(["blueutil", "--pair", device]) | |
if c == 0: | |
break | |
if c == 0: | |
print_notify(f"Paired {device}...", notify=notify) | |
if force_connect: | |
sleep(1) | |
run_command(["blueutil", "--connect", device]) | |
else: | |
print_notify(f"FAILED to pair {device} ({c})...", notify=notify) | |
def bluetooth_off(notify=False): | |
"""Unpairs all devices from the config file.""" | |
devices = get_device_ids() | |
for device in devices: | |
print(f"Unpairing {device}...") | |
c = run_command(["blueutil", "--unpair", device]) | |
if c == 0: | |
print_notify(f"Unpaired {device}...", notify=notify) | |
else: | |
print_notify(f"FAILED to unpair {device}...", notify=notify) | |
def update_script(): | |
"""Checks for updates and replaces the script if confirmed.""" | |
print("Checking for updates...") | |
try: | |
with urllib.request.urlopen(GIST_URL) as response: | |
latest_content = response.read().decode("utf-8") | |
except Exception as e: | |
print(f"Error: Failed to fetch latest script from Gist ({e})") | |
sys.exit(1) | |
script_path = Path(__file__) | |
current_content = script_path.read_text(encoding="utf-8") | |
if current_content.strip() == latest_content.strip(): | |
print("The script is already up to date.") | |
return | |
print("A new version is available. Do you want to update? (y/N): ", end="") | |
choice = input().strip().lower() | |
if choice == "y": | |
script_path.write_text(latest_content, encoding="utf-8") | |
print("Script updated successfully! Restarting...") | |
sys.exit(0) # Exit cleanly (user can restart manually) | |
else: | |
print("Update canceled.") | |
def ensure_config_file(): | |
"""Ensures the configuration directory and file exist.""" | |
CONFIG_DIR.mkdir(parents=True, exist_ok=True) | |
if not CONFIG_FILE.exists(): | |
CONFIG_FILE.write_text("# Add one device ID per line\n", encoding="utf-8") | |
print(f"Created config file at {CONFIG_FILE}. Please add your device IDs.") | |
def main(): | |
parser = argparse.ArgumentParser(description="Control Bluetooth devices using blueutil.") | |
parser.add_argument('--notify', action='store_true', help='Show notification after connecting or disconnecting devices') | |
subparsers = parser.add_subparsers(dest="command", required=True) | |
on_cmd = subparsers.add_parser("on", help="Unpair and re-pair all devices.") | |
on_cmd.add_argument('--retries', '-r', type=int, default=0, help="Number of pairing retries, as it sometimes fails for no good reason.") | |
on_cmd.add_argument('--connect', '-c', action="store_true", help="Force connect after pairing, as it sometimes does not connect automatically.") | |
subparsers.add_parser("off", help="Unpair all devices.") | |
subparsers.add_parser("update", help="Check for script updates.") | |
args = parser.parse_args() | |
ensure_config_file() | |
if args.command == "on": | |
bluetooth_on(retries=args.retries, force_connect=args.connect, notify=args.notify) | |
elif args.command == "off": | |
bluetooth_off(notify=args.notify) | |
elif args.command == "update": | |
update_script() | |
if __name__ == "__main__": | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment