Skip to content

Instantly share code, notes, and snippets.

@bburky
Last active September 5, 2026 03:19
Show Gist options
  • Select an option

  • Save bburky/4b7d9ce11c8c1dff96bb5c2a8d44505c to your computer and use it in GitHub Desktop.

Select an option

Save bburky/4b7d9ce11c8c1dff96bb5c2a8d44505c to your computer and use it in GitHub Desktop.
Keep selected Bluetooth devices paired and visible in GNOME while preventing them from auto-reconnecting

Bluetooth Manual-Only

Warning

This script is LLM slopcoded. But I read the code and tested all behavior paths that I know of. Read the code first and use at your own risk.

Keep selected Bluetooth devices paired and visible in GNOME while preventing them from reconnecting to the computer.

  • Appplies untrust to devices to prevent computer → device reconnection (re-applied on startup and device disconnect)
  • AuthorizeService() denial to prevent device → computer reconnection
  • Device can still be manually reconnected in GNOME Settings or Quick Settings

This was written for a bluetooth headset that aggressively reconnects to previously paired hosts. The headset stays paired, remains available in GNOME Quick Settings, and can still be connected manually from the normal GNOME Bluetooth UI.

Tested on Fedora Silverblue 43. Required Fedora packages (should be installed by default):

python3
python3-dbus
python3-gobject-base

Install

Edit MANUAL_ONLY in bluetooth-manual-only.py to add MAC addresses of any devices you would like to be controlled by this script.

Install the files:

install -Dm755 bluetooth-manual-only.py \
  ~/.local/libexec/bluetooth-manual-only.py

install -Dm644 bluetooth-manual-only.service \
  ~/.config/systemd/user/bluetooth-manual-only.service

For the cleanest first start, disconnect the headset if it is currently connected. Then enable the user service:

systemctl --user daemon-reload
systemctl --user enable --now bluetooth-manual-only.service

Check its status and logs with:

systemctl --user status bluetooth-manual-only.service
journalctl --user -u bluetooth-manual-only.service

No root privileges are required.

To stop using it:

systemctl --user disable --now bluetooth-manual-only.service

The device remains paired. If desired, restore the normal GNOME/BlueZ trust state afterward by connecting it and running:

bluetoothctl trust 12:34:56:AB:CD:EF

How it works

BlueZ distinguishes between the underlying Bluetooth connection and authorization of individual services such as A2DP, HFP, and AVRCP.

For the tested headset, a headset-initiated reconnect behaves like this:

headset initiates Bluetooth connection
        |
        v
Device1 Connected=True
        |
        v
BlueZ asks its default Agent1 to AuthorizeService()
        |
        v
bluetooth-manual-only rejects the service requests
        |
        v
BlueZ tears the connection back down

A manual connection from GNOME Quick Settings behaves differently. GNOME asks BlueZ to initiate the connection locally, and on the tested headset that path does not invoke AuthorizeService() on the default agent. The connection therefore proceeds normally.

The daemon exports a small org.bluez.Agent1 object and registers it with:

Capability = NoInputNoOutput

It intentionally calls RegisterAgent() but does not call RequestDefaultAgent().

When no other desktop agent is active, BlueZ can use this registered agent as its default. The agent rejects authorization and pairing requests, which closely matches the ordinary behavior when no interactive default agent is available. Trusted devices normally bypass this authorization path, so existing trusted Bluetooth devices continue to work normally.

Interaction with GNOME Bluetooth Settings

The full GNOME Settings → Bluetooth panel registers its own BlueZ agent and explicitly requests to become the default agent.

That is intentional and is left untouched.

While the Bluetooth Settings UI is open:

GNOME Settings agent
        |
        +-- becomes BlueZ's default agent
        |
        +-- normal GNOME authorization behavior applies

When GNOME Settings closes, its agent disappears and BlueZ can fall back to the already-registered bluetooth-manual-only agent. The daemon does not need to detect GNOME Settings, unregister itself, or proxy requests to GNOME.

GNOME's normal authorization behavior may set a paired device to Trusted=True. A trusted device bypasses AuthorizeService(), which would defeat the manual-only policy after GNOME Settings closes.

To avoid that, the daemon keeps every MAC in MANUAL_ONLY untrusted while it is disconnected.

It deliberately does not clear Trusted=True while the device is connected. This allows a connection accepted by the GNOME Bluetooth Settings UI to behave normally for the lifetime of that connection.

Once the headset disconnects:

Connected=False
        |
        v
daemon sets Trusted=False
        |
        v
next headset-initiated reconnect must use AuthorizeService()
        |
        v
rejected

If the daemon starts while a manual-only device is already connected, it likewise leaves the current trust state alone until that connection ends.

Why this does not use Blocked

BlueZ's Blocked property prevents the device from connecting at all. That also prevents the normal GNOME Connect button from working, so it does not meet the goal here.

The device remains:

Paired=True
Blocked=False
Trusted=False   # while disconnected

Why this does not use the kernel MGMT API

Linux's Bluetooth MGMT interface exposes enough information to distinguish locally initiated from remotely initiated connections, so an alternative implementation could watch MGMT connection events and disconnect selected remote-initiated links.

That approach requires lower-level access and additional privilege.

For the tested headset it is unnecessary: incoming HFP/A2DP/AVRCP service requests reliably invoke BlueZ Agent1.AuthorizeService(), while a manual GNOME Quick Settings connection does not. The Agent1 approach therefore provides the desired behavior entirely through the normal BlueZ D-Bus API and can run as an unprivileged systemd --user service.

Expected reconnect behavior

When the headset attempts an unsolicited reconnect, the base Bluetooth link may briefly appear as connected before BlueZ reaches service authorization.

On the tested headset the observed sequence was approximately:

Connected=True
AuthorizeService(HFP)  -> rejected
AuthorizeService(A2DP) -> rejected
AuthorizeService(AVRCP)-> rejected
~3 seconds
Connected=False

A brief connected indication is therefore expected.

BlueZ restarts

The daemon watches the org.bluez D-Bus owner. If bluetoothd restarts, it automatically registers its Agent1 again and re-applies the disconnected-device trust policy. No service restart should be necessary.

Adding more devices

Add MAC addresses to the MANUAL_ONLY set in the script:

MANUAL_ONLY = {
    "12:34:56:AB:CD:EF",
    "AA:BB:CC:DD:EE:FF",  # another device
}

Then restart the service:

systemctl --user restart bluetooth-manual-only.service

The approach depends on the device's remotely initiated profiles going through BlueZ service authorization. That behavior was explicitly tested with one headset; other devices may behave differently.

#!/usr/bin/env python3
import signal
import sys
from datetime import datetime
import dbus
import dbus.mainloop.glib
import dbus.service
from gi.repository import GLib
BLUEZ = "org.bluez"
AGENT_MANAGER_IFACE = "org.bluez.AgentManager1"
AGENT_IFACE = "org.bluez.Agent1"
DEVICE_IFACE = "org.bluez.Device1"
PROPERTIES_IFACE = "org.freedesktop.DBus.Properties"
OBJECT_MANAGER_IFACE = "org.freedesktop.DBus.ObjectManager"
DBUS_IFACE = "org.freedesktop.DBus"
AGENT_PATH = "/org/example/BluetoothManualOnly"
# Devices in this set are kept untrusted whenever disconnected. That makes
# remotely initiated service connections pass through AuthorizeService(),
# where this agent rejects them. Locally initiated GNOME Quick Settings
# connections do not hit AuthorizeService() for the tested headset.
MANUAL_ONLY = {
"12:34:56:AB:CD:EF",
}
class Rejected(dbus.DBusException):
_dbus_error_name = "org.bluez.Error.Rejected"
def log(message):
timestamp = datetime.now().astimezone().isoformat(timespec="seconds")
print(f"{timestamp} {message}", flush=True)
def mac_from_device_path(path):
marker = "/dev_"
path = str(path)
if marker not in path:
return None
suffix = path.rsplit(marker, 1)[1]
parts = suffix.split("_")
if len(parts) != 6 or any(len(part) != 2 for part in parts):
return None
return ":".join(parts).upper()
class ManualOnlyAgent(dbus.service.Object):
def __init__(self, daemon):
super().__init__(daemon.bus, AGENT_PATH)
self.daemon = daemon
def _device_label(self, path):
mac = mac_from_device_path(path)
if mac in MANUAL_ONLY:
return f"{mac} (manual-only)"
return mac or str(path)
@dbus.service.method(AGENT_IFACE, in_signature="", out_signature="")
def Release(self):
log("Agent released by BlueZ")
@dbus.service.method(AGENT_IFACE, in_signature="os", out_signature="")
def AuthorizeService(self, device, uuid):
# This deliberately matches the ordinary "no default agent" policy:
# untrusted incoming service authorization is rejected. MANUAL_ONLY
# devices are kept untrusted while disconnected, so their remote
# reconnect attempts always reach this method.
log(
f"Rejecting AuthorizeService from {self._device_label(device)} "
f"uuid={uuid}"
)
raise Rejected("Incoming service connections require manual connection")
@dbus.service.method(AGENT_IFACE, in_signature="o", out_signature="")
def RequestAuthorization(self, device):
log(f"Rejecting RequestAuthorization from {self._device_label(device)}")
raise Rejected("Authorization requires an interactive Bluetooth agent")
@dbus.service.method(AGENT_IFACE, in_signature="o", out_signature="s")
def RequestPinCode(self, device):
log(f"Rejecting RequestPinCode from {self._device_label(device)}")
raise Rejected("Pair devices through GNOME Bluetooth Settings")
@dbus.service.method(AGENT_IFACE, in_signature="os", out_signature="")
def DisplayPinCode(self, device, pincode):
log(f"DisplayPinCode for {self._device_label(device)}")
@dbus.service.method(AGENT_IFACE, in_signature="o", out_signature="u")
def RequestPasskey(self, device):
log(f"Rejecting RequestPasskey from {self._device_label(device)}")
raise Rejected("Pair devices through GNOME Bluetooth Settings")
@dbus.service.method(AGENT_IFACE, in_signature="ouq", out_signature="")
def DisplayPasskey(self, device, passkey, entered):
log(f"DisplayPasskey for {self._device_label(device)}")
@dbus.service.method(AGENT_IFACE, in_signature="ou", out_signature="")
def RequestConfirmation(self, device, passkey):
log(f"Rejecting RequestConfirmation from {self._device_label(device)}")
raise Rejected("Pair devices through GNOME Bluetooth Settings")
@dbus.service.method(AGENT_IFACE, in_signature="", out_signature="")
def Cancel(self):
log("Agent request canceled")
class ManualOnlyDaemon:
def __init__(self):
dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
self.bus = dbus.SystemBus()
self.loop = GLib.MainLoop()
self.agent = ManualOnlyAgent(self)
self.bluez_owner = None
self.registered = False
self.register_retry_source = None
self.device_state = {}
self.bus.add_signal_receiver(
self._properties_changed,
signal_name="PropertiesChanged",
dbus_interface=PROPERTIES_IFACE,
bus_name=BLUEZ,
path_keyword="path",
)
self.bus.add_signal_receiver(
self._interfaces_added,
signal_name="InterfacesAdded",
dbus_interface=OBJECT_MANAGER_IFACE,
bus_name=BLUEZ,
)
self.bus.add_signal_receiver(
self._interfaces_removed,
signal_name="InterfacesRemoved",
dbus_interface=OBJECT_MANAGER_IFACE,
bus_name=BLUEZ,
)
self.bus.add_signal_receiver(
self._name_owner_changed,
signal_name="NameOwnerChanged",
dbus_interface=DBUS_IFACE,
arg0=BLUEZ,
)
try:
self.bluez_owner = str(self.bus.get_name_owner(BLUEZ))
except dbus.DBusException:
self.bluez_owner = None
def _agent_manager(self):
return dbus.Interface(
self.bus.get_object(BLUEZ, "/org/bluez"),
AGENT_MANAGER_IFACE,
)
def _properties(self, path):
return dbus.Interface(
self.bus.get_object(BLUEZ, path),
PROPERTIES_IFACE,
)
def _set_trusted(self, path, trusted):
mac = mac_from_device_path(path)
if mac not in MANUAL_ONLY:
return
try:
props = self._properties(path)
current = bool(props.Get(DEVICE_IFACE, "Trusted"))
if current == trusted:
return
props.Set(DEVICE_IFACE, "Trusted", dbus.Boolean(trusted))
self.device_state.setdefault(path, {})["Trusted"] = trusted
log(f"Set Trusted={trusted} for {mac}")
except dbus.DBusException as exc:
log(f"Failed to set Trusted={trusted} for {mac}: {exc}")
def _ensure_untrusted_if_disconnected(self, path, props=None):
mac = mac_from_device_path(path)
if mac not in MANUAL_ONLY:
return
try:
if props is None:
iface = self._properties(path)
connected = bool(iface.Get(DEVICE_IFACE, "Connected"))
trusted = bool(iface.Get(DEVICE_IFACE, "Trusted"))
else:
connected = bool(props.get("Connected", False))
trusted = bool(props.get("Trusted", False))
self.device_state[path] = {
"Connected": connected,
"Trusted": trusted,
}
if not connected and trusted:
self._set_trusted(path, False)
elif not connected:
log(f"{mac} is disconnected and already untrusted")
else:
log(
f"{mac} is currently connected; leaving Trusted={trusted} "
"unchanged until disconnect"
)
except dbus.DBusException as exc:
log(f"Failed to inspect {mac}: {exc}")
def _refresh_devices(self):
if not self.bluez_owner:
return
try:
manager = dbus.Interface(
self.bus.get_object(BLUEZ, "/"),
OBJECT_MANAGER_IFACE,
)
objects = manager.GetManagedObjects()
except dbus.DBusException as exc:
log(f"Failed to enumerate BlueZ devices: {exc}")
return
seen = set()
for path, interfaces in objects.items():
device_props = interfaces.get(DEVICE_IFACE)
if not device_props:
continue
mac = str(device_props.get("Address", "")).upper()
if mac not in MANUAL_ONLY:
continue
path = str(path)
seen.add(mac)
self._ensure_untrusted_if_disconnected(path, device_props)
missing = MANUAL_ONLY - seen
for mac in sorted(missing):
log(f"Manual-only device is not currently present in BlueZ: {mac}")
def _register_agent(self):
self.register_retry_source = None
if not self.bluez_owner:
return GLib.SOURCE_REMOVE
try:
self._agent_manager().RegisterAgent(
AGENT_PATH,
"NoInputNoOutput",
)
self.registered = True
log(
"Registered NoInputNoOutput agent "
"(intentionally did not RequestDefaultAgent)"
)
self._refresh_devices()
return GLib.SOURCE_REMOVE
except dbus.DBusException as exc:
if exc.get_dbus_name() == "org.bluez.Error.AlreadyExists":
self.registered = True
log("Agent is already registered with BlueZ")
self._refresh_devices()
return GLib.SOURCE_REMOVE
log(f"Failed to register agent; retrying: {exc}")
self._schedule_register_retry()
return GLib.SOURCE_REMOVE
def _schedule_register_retry(self):
if self.register_retry_source is None:
self.register_retry_source = GLib.timeout_add_seconds(
2,
self._register_agent,
)
def _name_owner_changed(self, name, old_owner, new_owner):
if str(name) != BLUEZ:
return
self.bluez_owner = str(new_owner) or None
self.registered = False
if self.bluez_owner:
log(f"BlueZ appeared as {self.bluez_owner}")
GLib.idle_add(self._register_agent)
else:
log("BlueZ disappeared")
def _interfaces_added(self, path, interfaces):
device_props = interfaces.get(DEVICE_IFACE)
if not device_props:
return
mac = str(device_props.get("Address", "")).upper()
if mac not in MANUAL_ONLY:
return
self._ensure_untrusted_if_disconnected(str(path), device_props)
def _interfaces_removed(self, path, interfaces):
if DEVICE_IFACE in interfaces:
self.device_state.pop(str(path), None)
def _properties_changed(self, interface, changed, invalidated, path=None):
if str(interface) != DEVICE_IFACE or path is None:
return
path = str(path)
mac = mac_from_device_path(path)
if mac not in MANUAL_ONLY:
return
state = self.device_state.setdefault(path, {})
if "Connected" in changed:
state["Connected"] = bool(changed["Connected"])
log(f"{mac} Connected={state['Connected']}")
if "Trusted" in changed:
state["Trusted"] = bool(changed["Trusted"])
log(f"{mac} Trusted={state['Trusted']}")
connected = state.get("Connected")
trusted = state.get("Trusted")
# If this signal arrived before we had initial state, query Connected
# before deciding whether it is safe to clear Trusted.
if connected is None and trusted:
try:
connected = bool(
self._properties(path).Get(DEVICE_IFACE, "Connected")
)
state["Connected"] = connected
except dbus.DBusException as exc:
log(f"Failed to query Connected for {mac}: {exc}")
return
# GNOME Bluetooth Settings may temporarily become the default agent,
# authorize a remote connection, and set Trusted=True. Preserve that
# active connection. Once disconnected, restore Trusted=False so the
# next remote reconnect must pass through our agent.
if connected is False and trusted:
self._set_trusted(path, False)
def _unregister_agent(self):
if not self.registered or not self.bluez_owner:
return
try:
self._agent_manager().UnregisterAgent(AGENT_PATH)
log("Unregistered agent")
except dbus.DBusException:
# Process exit or bluetoothd shutdown makes explicit cleanup
# unnecessary; BlueZ drops agents when their bus owner vanishes.
pass
def stop(self, signum, _frame):
log(f"Stopping on signal {signum}")
self.loop.quit()
def run(self):
for signum in (signal.SIGINT, signal.SIGTERM):
signal.signal(signum, self.stop)
if self.bluez_owner:
GLib.idle_add(self._register_agent)
else:
log("BlueZ is not running; waiting for org.bluez to appear")
try:
self.loop.run()
finally:
self._unregister_agent()
return 0
def main():
for mac in MANUAL_ONLY:
parts = mac.split(":")
if len(parts) != 6 or any(len(part) != 2 for part in parts):
log(f"Invalid MANUAL_ONLY MAC address: {mac}")
return 2
return ManualOnlyDaemon().run()
if __name__ == "__main__":
sys.exit(main())
[Unit]
Description=Keep selected Bluetooth devices manual-connect only
Documentation=file:%h/.local/share/doc/bluetooth-manual-only/README.md
[Service]
Type=simple
ExecStart=%h/.local/libexec/bluetooth-manual-only.py
Restart=on-failure
RestartSec=2
NoNewPrivileges=yes
[Install]
WantedBy=default.target
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment