Run a command when a Bluetooth device connects or disconnects — with a way to say no.
I wanted one thing: pick up the Xbox controller, and the desk turns into a console. Monitors off, TV on, Steam in Big Picture. The obvious approach — a udev rule on the input device — turned out to be the wrong shape, because the interesting part is not detecting the controller. It is everything around it: the controller also connects when I press the Guide button at my desk to check the battery, and I would rather not have both monitors go black mid-sentence.
So bt-trigger listens to BlueZ on the system bus and, before it runs
anything, can put a notification with a Cancel button and a countdown on
your screen.
Python 3 and PyGObject. No daemon of its own to keep alive, no polling, no
bluetoothctl scraping.
- Fires on connect, disconnect, or both, per rule.
- Matches devices by MAC address or by name pattern (
*Controller*). - Runs any shell command, with the device in the environment.
- Optional countdown with a Cancel button before the command runs.
when/unlessconditions — shell commands that gate a rule, so it only fires when the TV is actually plugged in, or not when the mode is already on.gracefor disconnects — fire only if the device is still gone after N seconds. Bluetooth drops for a moment all the time, and a controller that fell asleep is not a controller that left.- A global off switch (
bt-trigger disable) that survives restarts. - Edge-triggered: only the transition fires, never the state. A device already connected when the daemon starts is remembered, not acted on.
Needs python-gobject (Arch), python3-gi (Debian/Ubuntu), python3-gobject
(Fedora) — plus a running bluetooth.service.
git clone https://github.com/maTzko13/bt-trigger.git
cd bt-trigger
install -Dm755 bt-trigger ~/.local/bin/bt-trigger
install -Dm644 bt-trigger.conf.example ~/.config/bt-trigger/bt-trigger.conf
bt-trigger list # your paired devices and their addresses
$EDITOR ~/.config/bt-trigger/bt-trigger.conf
bt-trigger dry-run couch-mode # show the countdown, print the command, run nothingThen install it as a user service — see examples/systemd.
INI. One [rule:NAME] per thing you want to happen.
[rule:couch-mode]
device = AA:BB:CC:11:22:33
event = connect
confirm = 15
command = ~/.local/bin/tv-game-mode on
when = ~/.local/bin/tv-game-mode available
unless = ~/.local/bin/tv-game-mode status --quiet| Key | Meaning |
|---|---|
device |
MAC address, name pattern (*Controller*, case insensitive) or *. Comma separated for several. |
event |
connect, disconnect or both. |
command |
Run through /bin/sh. Gets $BT_RULE, $BT_EVENT, $BT_DEVICE (MAC), $BT_DEVICE_NAME. |
confirm |
Seconds of cancellable countdown. Omit or 0 to run immediately. |
confirm_title, confirm_body |
Notification text. Placeholders: {name} {mac} {rule} {event} {seconds}. |
confirm_cancel_label, confirm_now_label, confirm_icon |
Notification looks. |
confirm_fallback |
No notification daemon: skip (default) or run. |
when |
Shell command that must succeed for the rule to fire. |
unless |
Shell command that, if it succeeds, skips the rule. |
grace |
Disconnect rules: fire only if still gone after N seconds. A reconnect cancels it. |
cooldown |
Ignore repeats of this rule for N seconds. Default 5. |
enabled |
false parks a rule without deleting it. |
bt-trigger.conf.example carries more rules than you want — headphones,
lock-when-away, any-gamepad — all switched off. Delete what you do not need.
bt-trigger [-c FILE] [-v] COMMAND [RULE]
| Command | Effect |
|---|---|
daemon |
Watch and act. The default, and what the unit runs. |
list |
Every device BlueZ knows: address, name, connected, paired, icon. |
monitor |
Print connect/disconnect events as they happen, act on nothing. Use this to find out what your device is actually called. |
status |
Config path, rules, armed or not. |
test RULE |
Run one rule now, countdown included. |
dry-run RULE |
Same, but print the command instead of running it. |
enable / disable / toggle |
Global off switch, persists across restarts. |
armed |
Exit 0 when armed. For a status bar or menu checkmark. |
systemctl --user reload bt-trigger (or kill -HUP) re-reads the config
without losing what the daemon knows about your devices.
Four decisions that are not obvious from the outside, and that you may want to disagree with:
The countdown does not ride on the notification's timeout. It has its own
timer. When a notification daemon decides to close a notification is its own
business — GNOME, mako, swaync and quickshell all behave differently, and some
ignore expire_timeout entirely. The command fires after exactly the seconds
you configured.
Dismissing the notification cancels; letting it expire does not. From
NotificationClosed, only reason 2 (dismissed by the user) counts as a
cancellation. Reason 1 (expired) is what happens when you ignore it, which is
consent. Reason 3 is us closing it ourselves.
If the notification cannot be posted, the command does not run. That is
confirm_fallback = skip, the default. A countdown nobody can see is not a
countdown, and asking for confirmation and then proceeding when nobody could
have answered is worse than not asking. Set confirm_fallback = run on
headless machines where you meant it.
Only edges, and the starting state is read, not acted on. At startup the
daemon asks BlueZ GetManagedObjects and records what is already connected.
Without that, every login with the controller still switched on would fire
every connect rule.
bt-trigger monitor # does BlueZ see the device at all?
bt-trigger -v daemon # verbose, in the foreground
journalctl --user -u bt-trigger -fNothing happens when the device connects:
bt-trigger monitorshows nothing → BlueZ is not reporting it. Some dongles connect as plain HID without a BlueZ device object.- It shows the event but no rule fires → check
deviceagainst the name inbt-trigger list, and remember thatwhen/unlesscan veto. - The rule fires but the command does nothing → it runs with the daemon's
environment. Test it with
systemd-run --user --pty sh -c 'your command'.
examples/couch-mode/ holds the actual script running
on my machine: displays, audio and Steam, plus the four things that cost me
time (why hyprctl cannot disable an output, why the way back must not need a
keyboard, why gamemoderun must not wrap Steam, and why the HDMI audio port
has to be found by name).
MIT. See LICENSE.