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                            if 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}
167
168struct DeviceMonitor {
169    mac: String,
170    adapter_mac: Option<String>,
171    manager_proxy: ObjectManagerProxy<'static>,
172    device: Option<Device>,
173}
174
175struct Device {
176    props: PropertiesProxy<'static>,
177    device: Device1Proxy<'static>,
178    battery: Battery1Proxy<'static>,
179}
180
181#[derive(Debug)]
182struct DeviceInfo {
183    connected: bool,
184    icon: &'static str,
185    name: String,
186    battery_percentage: Option<u8>,
187}
188
189impl DeviceMonitor {
190    async fn new(mac: String, adapter_mac: Option<String>) -> Result<Self> {
191        let dbus_conn = new_system_dbus_connection().await?;
192        let manager_proxy = ObjectManagerProxy::builder(&dbus_conn)
193            .destination("org.bluez")
194            .and_then(|x| x.path("/"))
195            .unwrap()
196            .build()
197            .await
198            .error("Failed to create ObjectManagerProxy")?;
199        let device = Device::try_find(&manager_proxy, &mac, adapter_mac.as_deref()).await?;
200        Ok(Self {
201            mac,
202            adapter_mac,
203            manager_proxy,
204            device,
205        })
206    }
207
208    async fn wait_for_change(&mut self) -> Result<()> {
209        match &mut self.device {
210            None => {
211                let mut interface_added = self
212                    .manager_proxy
213                    .receive_interfaces_added()
214                    .await
215                    .error("Failed to monitor interfaces")?;
216                loop {
217                    interface_added
218                        .next()
219                        .await
220                        .error("Stream ended unexpectedly")?;
221                    if let Some(device) = Device::try_find(
222                        &self.manager_proxy,
223                        &self.mac,
224                        self.adapter_mac.as_deref(),
225                    )
226                    .await?
227                    {
228                        self.device = Some(device);
229                        debug!("Device has been added");
230                        return Ok(());
231                    }
232                }
233            }
234            Some(device) => {
235                let mut updates = device
236                    .props
237                    .receive_properties_changed()
238                    .await
239                    .error("Failed to receive updates")?;
240
241                let mut interface_added = self
242                    .manager_proxy
243                    .receive_interfaces_added()
244                    .await
245                    .error("Failed to monitor interfaces")?;
246
247                let mut interface_removed = self
248                    .manager_proxy
249                    .receive_interfaces_removed()
250                    .await
251                    .error("Failed to monitor interfaces")?;
252
253                let mut bluez_owner_changed =
254                    DBusProxy::new(self.manager_proxy.inner().connection())
255                        .await
256                        .error("Failed to create DBusProxy")?
257                        .receive_name_owner_changed_with_args(&[(0, "org.bluez")])
258                        .await
259                        .unwrap();
260
261                loop {
262                    select! {
263                        _ = updates.next_debounced() => {
264                            debug!("Got update for device");
265                            return Ok(());
266                        }
267                        Some(event) = interface_added.next() => {
268                            let args = event.args().error("Failed to get the args")?;
269                            if args.object_path() == device.device.inner().path() {
270                                debug!("Interfaces added: {:?}", args.interfaces_and_properties().keys());
271                                return Ok(());
272                            }
273                        }
274                        Some(event) = interface_removed.next() => {
275                            let args = event.args().error("Failed to get the args")?;
276                            if args.object_path() == device.device.inner().path() {
277                                self.device = None;
278                                debug!("Device is no longer available");
279                                return Ok(());
280                            }
281                        }
282                        Some(event) = bluez_owner_changed.next() => {
283                            let args = event.args().error("Failed to get the args")?;
284                            if args.new_owner.is_none() {
285                                self.device = None;
286                                debug!("org.bluez disappeared");
287                                return Ok(());
288                            }
289                        }
290                    }
291                }
292            }
293        }
294    }
295
296    async fn get_device_info(&mut self) -> Option<DeviceInfo> {
297        let device = self.device.as_ref()?;
298
299        let Ok((connected, name)) =
300            tokio::try_join!(device.device.connected(), device.device.name(),)
301        else {
302            debug!("failed to fetch device info, assuming device or bluez disappeared");
303            self.device = None;
304            return None;
305        };
306
307        //icon can be null, so ignore errors when fetching it
308        let icon: &str = match device.device.icon().await.ok().as_deref() {
309            Some("audio-card" | "audio-headset" | "audio-headphones") => "headphones",
310            Some("input-gaming") => "joystick",
311            Some("input-keyboard") => "keyboard",
312            Some("input-mouse") => "mouse",
313            _ => "bluetooth",
314        };
315
316        Some(DeviceInfo {
317            connected,
318            icon,
319            name,
320            battery_percentage: device.battery.percentage().await.ok(),
321        })
322    }
323}
324
325impl Device {
326    async fn try_find(
327        manager_proxy: &ObjectManagerProxy<'_>,
328        mac: &str,
329        adapter_mac: Option<&str>,
330    ) -> Result<Option<Self>> {
331        let Ok(devices) = manager_proxy.get_managed_objects().await else {
332            debug!("could not get the list of managed objects");
333            return Ok(None);
334        };
335
336        debug!("all managed devices: {:?}", devices);
337
338        let root_object: Option<String> = match adapter_mac {
339            Some(adapter_mac) => {
340                let mut adapter_path = None;
341                for (path, interfaces) in &devices {
342                    let adapter_interface = match interfaces.get("org.bluez.Adapter1") {
343                        Some(i) => i,
344                        None => continue, // Not an adapter
345                    };
346                    let addr: &str = adapter_interface
347                        .get("Address")
348                        .and_then(|a| a.downcast_ref().ok())
349                        .unwrap();
350                    if addr == adapter_mac {
351                        adapter_path = Some(path);
352                        break;
353                    }
354                }
355                match adapter_path {
356                    Some(path) => Some(format!("{}/", path.as_str())),
357                    None => return Ok(None),
358                }
359            }
360            None => None,
361        };
362
363        debug!("root object: {:?}", root_object);
364
365        for (path, interfaces) in devices {
366            if let Some(root) = &root_object {
367                if !path.starts_with(root) {
368                    continue;
369                }
370            }
371
372            let Some(device_interface) = interfaces.get("org.bluez.Device1") else {
373                // Not a device
374                continue;
375            };
376
377            let addr: &str = device_interface
378                .get("Address")
379                .and_then(|a| a.downcast_ref().ok())
380                .unwrap();
381            if addr != mac {
382                continue;
383            }
384
385            debug!("Found device with path {:?}", path);
386
387            return Ok(Some(Self {
388                props: PropertiesProxy::builder(manager_proxy.inner().connection())
389                    .destination("org.bluez")
390                    .and_then(|x| x.path(path.clone()))
391                    .unwrap()
392                    .build()
393                    .await
394                    .error("Failed to create PropertiesProxy")?,
395                device: Device1Proxy::builder(manager_proxy.inner().connection())
396                    // No caching because https://github.com/greshake/i3status-rust/issues/1565#issuecomment-1379308681
397                    .cache_properties(zbus::proxy::CacheProperties::No)
398                    .path(path.clone())
399                    .unwrap()
400                    .build()
401                    .await
402                    .error("Failed to create Device1Proxy")?,
403                battery: Battery1Proxy::builder(manager_proxy.inner().connection())
404                    .cache_properties(zbus::proxy::CacheProperties::No)
405                    .path(path)
406                    .unwrap()
407                    .build()
408                    .await
409                    .error("Failed to create Battery1Proxy")?,
410            }));
411        }
412
413        debug!("No device found");
414        Ok(None)
415    }
416}
417
418#[zbus::proxy(interface = "org.bluez.Device1", default_service = "org.bluez")]
419trait Device1 {
420    fn connect(&self) -> zbus::Result<()>;
421    fn disconnect(&self) -> zbus::Result<()>;
422
423    #[zbus(property)]
424    fn connected(&self) -> zbus::Result<bool>;
425
426    #[zbus(property)]
427    fn name(&self) -> zbus::Result<String>;
428
429    #[zbus(property)]
430    fn icon(&self) -> zbus::Result<String>;
431}
432
433#[zbus::proxy(interface = "org.bluez.Battery1", default_service = "org.bluez")]
434trait Battery1 {
435    #[zbus(property)]
436    fn percentage(&self) -> zbus::Result<u8>;
437}