Skip to main content

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` (`$icon`) for bluetooth devices identifying as "audio-card", "audio-headset" or "audio-headphones"
55//! - `joystick` (`$icon`) for bluetooth devices identifying as "input-gaming"
56//! - `keyboard` (`$icon`) for bluetooth devices identifying as "input-keyboard"
57//! - `mouse` (`$icon`) for bluetooth devices identifying as "input-mouse"
58//! - `bluetooth` (`$icon`) for all other devices, and when the device is unavailable
59//! - `bat` (`$battery_icon`, as a progression) for devices reporting a battery level
60
61use zbus::fdo::{DBusProxy, ObjectManagerProxy, PropertiesProxy};
62
63use super::prelude::*;
64use crate::wrappers::RangeMap;
65
66make_log_macro!(debug, "bluetooth");
67
68#[derive(Deserialize, Debug)]
69#[serde(deny_unknown_fields)]
70pub struct Config {
71    pub mac: String,
72    #[serde(default)]
73    pub adapter_mac: Option<String>,
74    #[serde(default)]
75    pub format: FormatConfig,
76    #[serde(default)]
77    pub disconnected_format: FormatConfig,
78    #[serde(default)]
79    pub battery_state: Option<RangeMap<u8, State>>,
80}
81
82/// Every icon name [`device_icon`] can return. The device type is externally
83/// selected but finite, so the block plan declares the full set.
84const DEVICE_ICONS: [&str; 5] = [
85    icons::HEADPHONES,
86    icons::JOYSTICK,
87    icons::KEYBOARD,
88    icons::MOUSE,
89    icons::BLUETOOTH,
90];
91
92fn device_icon(bluez_icon: Option<&str>) -> &'static str {
93    match bluez_icon {
94        Some("audio-card" | "audio-headset" | "audio-headphones") => icons::HEADPHONES,
95        Some("input-gaming") => icons::JOYSTICK,
96        Some("input-keyboard") => icons::KEYBOARD,
97        Some("input-mouse") => icons::MOUSE,
98        _ => icons::BLUETOOTH,
99    }
100}
101
102pub(crate) fn prepare(config: &Config) -> Result<Arc<BlockPlan>> {
103    let format = config.format.with_default(" $icon $name{ $percentage|} ")?;
104    let disconnected_format = config
105        .disconnected_format
106        .with_default(" $icon{ $name|} ")?;
107    BlockPlan::new(vec![
108        OutputPlan::new("connected", format)
109            .icon("icon", IconChoices::fixed(DEVICE_ICONS))
110            .icon("battery_icon", IconChoices::one(icons::BAT)),
111        OutputPlan::new("disconnected", disconnected_format.clone())
112            .icon("icon", IconChoices::fixed(DEVICE_ICONS))
113            .icon("battery_icon", IconChoices::one(icons::BAT)),
114        OutputPlan::new("unavailable", disconnected_format)
115            .icon("icon", IconChoices::one(icons::BLUETOOTH)),
116    ])
117}
118
119pub(crate) async fn run(config: &Config, api: &CommonApi, plan: &Arc<BlockPlan>) -> Result<()> {
120    let mut actions = api.get_actions()?;
121    api.set_default_actions(&[(MouseButton::Right, None, "toggle")])?;
122
123    let output_connected = plan.output("connected")?;
124    let output_disconnected = plan.output("disconnected")?;
125    let output_unavailable = plan.output("unavailable")?;
126
127    let mut monitor = DeviceMonitor::new(config.mac.clone(), config.adapter_mac.clone()).await?;
128
129    let battery_states = config.battery_state.clone().unwrap_or_else(|| {
130        vec![
131            (0..=15, State::Critical),
132            (16..=30, State::Warning),
133            (31..=60, State::Info),
134            (61..=100, State::Good),
135        ]
136        .into()
137    });
138
139    loop {
140        match monitor.get_device_info().await {
141            // Available
142            Some(device) => {
143                debug!("Device available, info: {device:?}");
144
145                let output = if device.connected {
146                    &output_connected
147                } else {
148                    &output_disconnected
149                };
150                let mut widget = output.new_widget();
151
152                let values = map! {
153                    "icon" => Value::icon(device.icon),
154                    "name" => Value::text(device.name),
155                    "available" => Value::flag(),
156                    [if let Some(p) = device.battery_percentage] "percentage" => Value::percents(p),
157                    [if let Some(p) = device.battery_percentage]
158                        "battery_icon" => Value::icon_progression(icons::BAT, p as f64 / 100.0),
159                };
160
161                if device.connected {
162                    widget.state = battery_states
163                        .get(&device.battery_percentage.unwrap_or(100))
164                        .copied()
165                        .unwrap_or(State::Good);
166                } else {
167                    widget.state = State::Idle;
168                }
169
170                widget.set_values(values);
171                api.set_widget(widget)?;
172            }
173            // Unavailable
174            None => {
175                debug!("Showing device as unavailable");
176                let mut widget = output_unavailable.new_widget();
177                widget.set_values(map!("icon" => Value::icon(icons::BLUETOOTH)));
178                api.set_widget(widget)?;
179            }
180        }
181
182        loop {
183            select! {
184                res = monitor.wait_for_change() => {
185                    res?;
186                    break;
187                },
188                Some(action) = actions.recv() => match action.as_ref() {
189                    "toggle" => {
190                        if let Some(dev) = &monitor.device
191                            && let Ok(connected) = dev.device.connected().await {
192                                if connected {
193                                    let _ = dev.device.disconnect().await;
194                                } else {
195                                    let _ = dev.device.connect().await;
196                                }
197                                break;
198                            }
199                    }
200                    _ => (),
201                }
202            }
203        }
204    }
205}
206
207struct DeviceMonitor {
208    mac: String,
209    adapter_mac: Option<String>,
210    manager_proxy: ObjectManagerProxy<'static>,
211    device: Option<Device>,
212}
213
214struct Device {
215    props: PropertiesProxy<'static>,
216    device: Device1Proxy<'static>,
217    battery: Battery1Proxy<'static>,
218}
219
220#[derive(Debug)]
221struct DeviceInfo {
222    connected: bool,
223    icon: &'static str,
224    name: String,
225    battery_percentage: Option<u8>,
226}
227
228impl DeviceMonitor {
229    async fn new(mac: String, adapter_mac: Option<String>) -> Result<Self> {
230        let dbus_conn = new_system_dbus_connection().await?;
231        let manager_proxy = ObjectManagerProxy::builder(&dbus_conn)
232            .destination("org.bluez")
233            .and_then(|x| x.path("/"))
234            .unwrap()
235            .build()
236            .await
237            .error("Failed to create ObjectManagerProxy")?;
238        let device = Device::try_find(&manager_proxy, &mac, adapter_mac.as_deref()).await?;
239        Ok(Self {
240            mac,
241            adapter_mac,
242            manager_proxy,
243            device,
244        })
245    }
246
247    async fn wait_for_change(&mut self) -> Result<()> {
248        match &mut self.device {
249            None => {
250                let mut interface_added = self
251                    .manager_proxy
252                    .receive_interfaces_added()
253                    .await
254                    .error("Failed to monitor interfaces")?;
255                loop {
256                    interface_added
257                        .next()
258                        .await
259                        .error("Stream ended unexpectedly")?;
260                    if let Some(device) = Device::try_find(
261                        &self.manager_proxy,
262                        &self.mac,
263                        self.adapter_mac.as_deref(),
264                    )
265                    .await?
266                    {
267                        self.device = Some(device);
268                        debug!("Device has been added");
269                        return Ok(());
270                    }
271                }
272            }
273            Some(device) => {
274                let mut updates = device
275                    .props
276                    .receive_properties_changed()
277                    .await
278                    .error("Failed to receive updates")?;
279
280                let mut interface_added = self
281                    .manager_proxy
282                    .receive_interfaces_added()
283                    .await
284                    .error("Failed to monitor interfaces")?;
285
286                let mut interface_removed = self
287                    .manager_proxy
288                    .receive_interfaces_removed()
289                    .await
290                    .error("Failed to monitor interfaces")?;
291
292                let mut bluez_owner_changed =
293                    DBusProxy::new(self.manager_proxy.inner().connection())
294                        .await
295                        .error("Failed to create DBusProxy")?
296                        .receive_name_owner_changed_with_args(&[(0, "org.bluez")])
297                        .await
298                        .unwrap();
299
300                loop {
301                    select! {
302                        _ = updates.next_debounced() => {
303                            debug!("Got update for device");
304                            return Ok(());
305                        }
306                        Some(event) = interface_added.next() => {
307                            let args = event.args().error("Failed to get the args")?;
308                            if args.object_path() == device.device.inner().path() {
309                                debug!("Interfaces added: {:?}", args.interfaces_and_properties().keys());
310                                return Ok(());
311                            }
312                        }
313                        Some(event) = interface_removed.next() => {
314                            let args = event.args().error("Failed to get the args")?;
315                            if args.object_path() == device.device.inner().path() {
316                                self.device = None;
317                                debug!("Device is no longer available");
318                                return Ok(());
319                            }
320                        }
321                        Some(event) = bluez_owner_changed.next() => {
322                            let args = event.args().error("Failed to get the args")?;
323                            if args.new_owner.is_none() {
324                                self.device = None;
325                                debug!("org.bluez disappeared");
326                                return Ok(());
327                            }
328                        }
329                    }
330                }
331            }
332        }
333    }
334
335    async fn get_device_info(&mut self) -> Option<DeviceInfo> {
336        let device = self.device.as_ref()?;
337
338        let Ok((connected, name)) =
339            tokio::try_join!(device.device.connected(), device.device.name(),)
340        else {
341            debug!("failed to fetch device info, assuming device or bluez disappeared");
342            self.device = None;
343            return None;
344        };
345
346        //icon can be null, so ignore errors when fetching it
347        let icon: &str = device_icon(device.device.icon().await.ok().as_deref());
348
349        Some(DeviceInfo {
350            connected,
351            icon,
352            name,
353            battery_percentage: device.battery.percentage().await.ok(),
354        })
355    }
356}
357
358impl Device {
359    async fn try_find(
360        manager_proxy: &ObjectManagerProxy<'_>,
361        mac: &str,
362        adapter_mac: Option<&str>,
363    ) -> Result<Option<Self>> {
364        let Ok(devices) = manager_proxy.get_managed_objects().await else {
365            debug!("could not get the list of managed objects");
366            return Ok(None);
367        };
368
369        debug!("all managed devices: {:?}", devices);
370
371        let root_object: Option<String> = match adapter_mac {
372            Some(adapter_mac) => {
373                let mut adapter_path = None;
374                for (path, interfaces) in &devices {
375                    let adapter_interface = match interfaces.get("org.bluez.Adapter1") {
376                        Some(i) => i,
377                        None => continue, // Not an adapter
378                    };
379                    let addr: &str = adapter_interface
380                        .get("Address")
381                        .and_then(|a| a.downcast_ref().ok())
382                        .unwrap();
383                    if addr == adapter_mac {
384                        adapter_path = Some(path);
385                        break;
386                    }
387                }
388                match adapter_path {
389                    Some(path) => Some(format!("{}/", path.as_str())),
390                    None => return Ok(None),
391                }
392            }
393            None => None,
394        };
395
396        debug!("root object: {:?}", root_object);
397
398        for (path, interfaces) in devices {
399            if let Some(root) = &root_object
400                && !path.starts_with(root)
401            {
402                continue;
403            }
404
405            let Some(device_interface) = interfaces.get("org.bluez.Device1") else {
406                // Not a device
407                continue;
408            };
409
410            let addr: &str = device_interface
411                .get("Address")
412                .and_then(|a| a.downcast_ref().ok())
413                .unwrap();
414            if addr != mac {
415                continue;
416            }
417
418            debug!("Found device with path {:?}", path);
419
420            return Ok(Some(Self {
421                props: PropertiesProxy::builder(manager_proxy.inner().connection())
422                    .destination("org.bluez")
423                    .and_then(|x| x.path(path.clone()))
424                    .unwrap()
425                    .build()
426                    .await
427                    .error("Failed to create PropertiesProxy")?,
428                device: Device1Proxy::builder(manager_proxy.inner().connection())
429                    // No caching because https://github.com/greshake/i3status-rust/issues/1565#issuecomment-1379308681
430                    .cache_properties(zbus::proxy::CacheProperties::No)
431                    .path(path.clone())
432                    .unwrap()
433                    .build()
434                    .await
435                    .error("Failed to create Device1Proxy")?,
436                battery: Battery1Proxy::builder(manager_proxy.inner().connection())
437                    .cache_properties(zbus::proxy::CacheProperties::No)
438                    .path(path)
439                    .unwrap()
440                    .build()
441                    .await
442                    .error("Failed to create Battery1Proxy")?,
443            }));
444        }
445
446        debug!("No device found");
447        Ok(None)
448    }
449}
450
451#[zbus::proxy(interface = "org.bluez.Device1", default_service = "org.bluez")]
452trait Device1 {
453    fn connect(&self) -> zbus::Result<()>;
454    fn disconnect(&self) -> zbus::Result<()>;
455
456    #[zbus(property)]
457    fn connected(&self) -> zbus::Result<bool>;
458
459    #[zbus(property)]
460    fn name(&self) -> zbus::Result<String>;
461
462    #[zbus(property)]
463    fn icon(&self) -> zbus::Result<String>;
464}
465
466#[zbus::proxy(interface = "org.bluez.Battery1", default_service = "org.bluez")]
467trait Battery1 {
468    #[zbus(property)]
469    fn percentage(&self) -> zbus::Result<u8>;
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475
476    fn config() -> Config {
477        Config {
478            mac: "00:00:00:00:00:00".into(),
479            adapter_mac: None,
480            format: Default::default(),
481            disconnected_format: Default::default(),
482            battery_state: None,
483        }
484    }
485
486    #[test]
487    fn plan_declares_every_state_with_its_icons() {
488        let plan = prepare(&config()).unwrap();
489        let ids: Vec<_> = plan.outputs().map(|o| o.id()).collect();
490        assert_eq!(ids, ["connected", "disconnected", "unavailable"]);
491
492        // The device values (icon, battery_icon) are set regardless of the
493        // connection state, so both connected and disconnected declare them.
494        for id in ["connected", "disconnected"] {
495            let output = plan.output(id).unwrap();
496            let choices = output.output().choices_for("icon").unwrap();
497            for name in DEVICE_ICONS {
498                assert!(choices.permits(name), "{id} must permit {name}");
499            }
500            assert_eq!(output.single_icon("battery_icon").unwrap(), "bat");
501        }
502
503        let unavailable = plan.output("unavailable").unwrap();
504        assert_eq!(unavailable.single_icon("icon").unwrap(), "bluetooth");
505        assert!(
506            unavailable.output().choices_for("battery_icon").is_none(),
507            "battery values are never set when the device is unavailable"
508        );
509    }
510
511    #[test]
512    fn every_device_icon_is_declared() {
513        // Tie the runtime chooser to the declared set.
514        for input in [
515            Some("audio-card"),
516            Some("audio-headset"),
517            Some("audio-headphones"),
518            Some("input-gaming"),
519            Some("input-keyboard"),
520            Some("input-mouse"),
521            Some("something-else"),
522            None,
523        ] {
524            assert!(DEVICE_ICONS.contains(&device_icon(input)));
525        }
526    }
527
528    #[test]
529    fn unavailable_shares_the_disconnected_format() {
530        let config = Config {
531            disconnected_format: " off ".parse().unwrap(),
532            ..config()
533        };
534        let plan = prepare(&config).unwrap();
535        for id in ["disconnected", "unavailable"] {
536            assert!(!plan.output(id).unwrap().format().contains_key("icon"));
537        }
538        assert!(
539            plan.output("connected")
540                .unwrap()
541                .format()
542                .contains_key("icon")
543        );
544    }
545}