i3status_rs/blocks/
bluetooth.rs

1//! Monitor Bluetooth device
2//!
3//! This block displays the connectivity of a given Bluetooth device and the battery level if this
4//! is supported. Relies on the Bluez D-Bus API.
5//!
6//! When the device can be identified as an audio headset, a keyboard, joystick, or mouse, use the
7//! relevant icon. Otherwise, fall back on the generic Bluetooth symbol.
8//!
9//! Right-clicking the block will attempt to connect (or disconnect) the device.
10//!
11//! Note: battery level information is not reported for some devices. [Enabling experimental
12//! features of `bluez`](https://wiki.archlinux.org/title/bluetooth#Enabling_experimental_features)
13//! may fix it.
14//!
15//! # Configuration
16//!
17//! Key | Values | Default
18//! ----|--------|--------
19//! `mac` | MAC address of the Bluetooth device | **Required**
20//! `adapter_mac` | MAC Address of the Bluetooth adapter (in case your device was connected to multiple currently available adapters) | `None`
21//! `format` | A string to customise the output of this block. See below for available placeholders. | <code>\" $icon $name{ $percentage\|} \"</code>
22//! `disconnected_format` | A string to customise the output of this block. See below for available placeholders. | <code>\" $icon{ $name\|} \"</code>
23//! `battery_state` | A mapping from battery percentage to block's [state](State) (color). See example below. | 0..15 -> critical, 16..30 -> warning, 31..60 -> info, 61..100 -> good
24//!
25//! Placeholder    | Value                                                                 | Type   | Unit
26//! ---------------|-----------------------------------------------------------------------|--------|------
27//! `icon`         | Icon based on what type of device is connected                        | Icon   | -
28//! `name`         | Device's name                                                         | Text   | -
29//! `percentage`   | Device's battery level (may be absent if the device is not supported) | Number | %
30//! `battery_icon` | Battery icon (may be absent if the device is not supported)           | Icon   | -
31//! `available`    | Present if the device is available                                    | Flag   | -
32//!
33//! Action   | Default button
34//! ---------|---------------
35//! `toggle` | Right
36//!
37//! # Examples
38//!
39//! This example just shows the icon when device is connected.
40//!
41//! ```toml
42//! [[block]]
43//! block = "bluetooth"
44//! mac = "00:18:09:92:1B:BA"
45//! disconnected_format = ""
46//! format = " $icon "
47//! [block.battery_state]
48//! "0..20" = "critical"
49//! "21..70" = "warning"
50//! "71..100" = "good"
51//! ```
52//!
53//! # Icons Used
54//! - `headphones` for bluetooth devices identifying as "audio-card", "audio-headset" or "audio-headphones"
55//! - `joystick` for bluetooth devices identifying as "input-gaming"
56//! - `keyboard` for bluetooth devices identifying as "input-keyboard"
57//! - `mouse` for bluetooth devices identifying as "input-mouse"
58//! - `bluetooth` for all other devices
59
60use zbus::fdo::{DBusProxy, ObjectManagerProxy, PropertiesProxy};
61
62use super::prelude::*;
63use crate::wrappers::RangeMap;
64
65make_log_macro!(debug, "bluetooth");
66
67#[derive(Deserialize, Debug)]
68#[serde(deny_unknown_fields)]
69pub struct Config {
70    pub mac: String,
71    #[serde(default)]
72    pub adapter_mac: Option<String>,
73    #[serde(default)]
74    pub format: FormatConfig,
75    #[serde(default)]
76    pub disconnected_format: FormatConfig,
77    #[serde(default)]
78    pub battery_state: Option<RangeMap<u8, State>>,
79}
80
81pub async fn run(config: &Config, api: &CommonApi) -> Result<()> {
82    let mut actions = api.get_actions()?;
83    api.set_default_actions(&[(MouseButton::Right, None, "toggle")])?;
84
85    let format = config.format.with_default(" $icon $name{ $percentage|} ")?;
86    let disconnected_format = config
87        .disconnected_format
88        .with_default(" $icon{ $name|} ")?;
89
90    let mut monitor = DeviceMonitor::new(config.mac.clone(), config.adapter_mac.clone()).await?;
91
92    let battery_states = config.battery_state.clone().unwrap_or_else(|| {
93        vec![
94            (0..=15, State::Critical),
95            (16..=30, State::Warning),
96            (31..=60, State::Info),
97            (61..=100, State::Good),
98        ]
99        .into()
100    });
101
102    loop {
103        match monitor.get_device_info().await {
104            // Available
105            Some(device) => {
106                debug!("Device available, info: {device:?}");
107
108                let mut widget = Widget::new();
109
110                let values = map! {
111                    "icon" => Value::icon(device.icon),
112                    "name" => Value::text(device.name),
113                    "available" => Value::flag(),
114                    [if let Some(p) = device.battery_percentage] "percentage" => Value::percents(p),
115                    [if let Some(p) = device.battery_percentage]
116                        "battery_icon" => Value::icon_progression("bat", p as f64 / 100.0),
117                };
118
119                if device.connected {
120                    widget.set_format(format.clone());
121                    widget.state = battery_states
122                        .get(&device.battery_percentage.unwrap_or(100))
123                        .copied()
124                        .unwrap_or(State::Good);
125                } else {
126                    widget.set_format(disconnected_format.clone());
127                    widget.state = State::Idle;
128                }
129
130                widget.set_values(values);
131                api.set_widget(widget)?;
132            }
133            // Unavailable
134            None => {
135                debug!("Showing device as unavailable");
136                let mut widget = Widget::new().with_format(disconnected_format.clone());
137                widget.set_values(map!("icon" => Value::icon("bluetooth")));
138                api.set_widget(widget)?;
139            }
140        }
141
142        loop {
143            select! {
144                res = monitor.wait_for_change() => {
145                    res?;
146                    break;
147                },
148                Some(action) = actions.recv() => match action.as_ref() {
149                    "toggle" => {
150                        if let Some(dev) = &monitor.device
151                            && let Ok(connected) = dev.device.connected().await {
152                                if connected {
153                                    let _ = dev.device.disconnect().await;
154                                } else {
155                                    let _ = dev.device.connect().await;
156                                }
157                                break;
158                            }
159                    }
160                    _ => (),
161                }
162            }
163        }
164    }
165}
166
167struct DeviceMonitor {
168    mac: String,
169    adapter_mac: Option<String>,
170    manager_proxy: ObjectManagerProxy<'static>,
171    device: Option<Device>,
172}
173
174struct Device {
175    props: PropertiesProxy<'static>,
176    device: Device1Proxy<'static>,
177    battery: Battery1Proxy<'static>,
178}
179
180#[derive(Debug)]
181struct DeviceInfo {
182    connected: bool,
183    icon: &'static str,
184    name: String,
185    battery_percentage: Option<u8>,
186}
187
188impl DeviceMonitor {
189    async fn new(mac: String, adapter_mac: Option<String>) -> Result<Self> {
190        let dbus_conn = new_system_dbus_connection().await?;
191        let manager_proxy = ObjectManagerProxy::builder(&dbus_conn)
192            .destination("org.bluez")
193            .and_then(|x| x.path("/"))
194            .unwrap()
195            .build()
196            .await
197            .error("Failed to create ObjectManagerProxy")?;
198        let device = Device::try_find(&manager_proxy, &mac, adapter_mac.as_deref()).await?;
199        Ok(Self {
200            mac,
201            adapter_mac,
202            manager_proxy,
203            device,
204        })
205    }
206
207    async fn wait_for_change(&mut self) -> Result<()> {
208        match &mut self.device {
209            None => {
210                let mut interface_added = self
211                    .manager_proxy
212                    .receive_interfaces_added()
213                    .await
214                    .error("Failed to monitor interfaces")?;
215                loop {
216                    interface_added
217                        .next()
218                        .await
219                        .error("Stream ended unexpectedly")?;
220                    if let Some(device) = Device::try_find(
221                        &self.manager_proxy,
222                        &self.mac,
223                        self.adapter_mac.as_deref(),
224                    )
225                    .await?
226                    {
227                        self.device = Some(device);
228                        debug!("Device has been added");
229                        return Ok(());
230                    }
231                }
232            }
233            Some(device) => {
234                let mut updates = device
235                    .props
236                    .receive_properties_changed()
237                    .await
238                    .error("Failed to receive updates")?;
239
240                let mut interface_added = self
241                    .manager_proxy
242                    .receive_interfaces_added()
243                    .await
244                    .error("Failed to monitor interfaces")?;
245
246                let mut interface_removed = self
247                    .manager_proxy
248                    .receive_interfaces_removed()
249                    .await
250                    .error("Failed to monitor interfaces")?;
251
252                let mut bluez_owner_changed =
253                    DBusProxy::new(self.manager_proxy.inner().connection())
254                        .await
255                        .error("Failed to create DBusProxy")?
256                        .receive_name_owner_changed_with_args(&[(0, "org.bluez")])
257                        .await
258                        .unwrap();
259
260                loop {
261                    select! {
262                        _ = updates.next_debounced() => {
263                            debug!("Got update for device");
264                            return Ok(());
265                        }
266                        Some(event) = interface_added.next() => {
267                            let args = event.args().error("Failed to get the args")?;
268                            if args.object_path() == device.device.inner().path() {
269                                debug!("Interfaces added: {:?}", args.interfaces_and_properties().keys());
270                                return Ok(());
271                            }
272                        }
273                        Some(event) = interface_removed.next() => {
274                            let args = event.args().error("Failed to get the args")?;
275                            if args.object_path() == device.device.inner().path() {
276                                self.device = None;
277                                debug!("Device is no longer available");
278                                return Ok(());
279                            }
280                        }
281                        Some(event) = bluez_owner_changed.next() => {
282                            let args = event.args().error("Failed to get the args")?;
283                            if args.new_owner.is_none() {
284                                self.device = None;
285                                debug!("org.bluez disappeared");
286                                return Ok(());
287                            }
288                        }
289                    }
290                }
291            }
292        }
293    }
294
295    async fn get_device_info(&mut self) -> Option<DeviceInfo> {
296        let device = self.device.as_ref()?;
297
298        let Ok((connected, name)) =
299            tokio::try_join!(device.device.connected(), device.device.name(),)
300        else {
301            debug!("failed to fetch device info, assuming device or bluez disappeared");
302            self.device = None;
303            return None;
304        };
305
306        //icon can be null, so ignore errors when fetching it
307        let icon: &str = match device.device.icon().await.ok().as_deref() {
308            Some("audio-card" | "audio-headset" | "audio-headphones") => "headphones",
309            Some("input-gaming") => "joystick",
310            Some("input-keyboard") => "keyboard",
311            Some("input-mouse") => "mouse",
312            _ => "bluetooth",
313        };
314
315        Some(DeviceInfo {
316            connected,
317            icon,
318            name,
319            battery_percentage: device.battery.percentage().await.ok(),
320        })
321    }
322}
323
324impl Device {
325    async fn try_find(
326        manager_proxy: &ObjectManagerProxy<'_>,
327        mac: &str,
328        adapter_mac: Option<&str>,
329    ) -> Result<Option<Self>> {
330        let Ok(devices) = manager_proxy.get_managed_objects().await else {
331            debug!("could not get the list of managed objects");
332            return Ok(None);
333        };
334
335        debug!("all managed devices: {:?}", devices);
336
337        let root_object: Option<String> = match adapter_mac {
338            Some(adapter_mac) => {
339                let mut adapter_path = None;
340                for (path, interfaces) in &devices {
341                    let adapter_interface = match interfaces.get("org.bluez.Adapter1") {
342                        Some(i) => i,
343                        None => continue, // Not an adapter
344                    };
345                    let addr: &str = adapter_interface
346                        .get("Address")
347                        .and_then(|a| a.downcast_ref().ok())
348                        .unwrap();
349                    if addr == adapter_mac {
350                        adapter_path = Some(path);
351                        break;
352                    }
353                }
354                match adapter_path {
355                    Some(path) => Some(format!("{}/", path.as_str())),
356                    None => return Ok(None),
357                }
358            }
359            None => None,
360        };
361
362        debug!("root object: {:?}", root_object);
363
364        for (path, interfaces) in devices {
365            if let Some(root) = &root_object
366                && !path.starts_with(root)
367            {
368                continue;
369            }
370
371            let Some(device_interface) = interfaces.get("org.bluez.Device1") else {
372                // Not a device
373                continue;
374            };
375
376            let addr: &str = device_interface
377                .get("Address")
378                .and_then(|a| a.downcast_ref().ok())
379                .unwrap();
380            if addr != mac {
381                continue;
382            }
383
384            debug!("Found device with path {:?}", path);
385
386            return Ok(Some(Self {
387                props: PropertiesProxy::builder(manager_proxy.inner().connection())
388                    .destination("org.bluez")
389                    .and_then(|x| x.path(path.clone()))
390                    .unwrap()
391                    .build()
392                    .await
393                    .error("Failed to create PropertiesProxy")?,
394                device: Device1Proxy::builder(manager_proxy.inner().connection())
395                    // No caching because https://github.com/greshake/i3status-rust/issues/1565#issuecomment-1379308681
396                    .cache_properties(zbus::proxy::CacheProperties::No)
397                    .path(path.clone())
398                    .unwrap()
399                    .build()
400                    .await
401                    .error("Failed to create Device1Proxy")?,
402                battery: Battery1Proxy::builder(manager_proxy.inner().connection())
403                    .cache_properties(zbus::proxy::CacheProperties::No)
404                    .path(path)
405                    .unwrap()
406                    .build()
407                    .await
408                    .error("Failed to create Battery1Proxy")?,
409            }));
410        }
411
412        debug!("No device found");
413        Ok(None)
414    }
415}
416
417#[zbus::proxy(interface = "org.bluez.Device1", default_service = "org.bluez")]
418trait Device1 {
419    fn connect(&self) -> zbus::Result<()>;
420    fn disconnect(&self) -> zbus::Result<()>;
421
422    #[zbus(property)]
423    fn connected(&self) -> zbus::Result<bool>;
424
425    #[zbus(property)]
426    fn name(&self) -> zbus::Result<String>;
427
428    #[zbus(property)]
429    fn icon(&self) -> zbus::Result<String>;
430}
431
432#[zbus::proxy(interface = "org.bluez.Battery1", default_service = "org.bluez")]
433trait Battery1 {
434    #[zbus(property)]
435    fn percentage(&self) -> zbus::Result<u8>;
436}