i3status_rs/blocks/
kdeconnect.rs

1//! [KDEConnect](https://community.kde.org/KDEConnect) indicator
2//!
3//! Display info from the currently connected device in KDEConnect, updated asynchronously.
4//!
5//! Block colours are updated based on the battery level, unless all bat_* thresholds are set to 0,
6//! in which case the block colours will depend on the notification count instead.
7//!
8//! # Configuration
9//!
10//! Key | Values | Default
11//! ----|--------|--------
12//! `device_id` | Device ID as per the output of `kdeconnect --list-devices`. | Chooses the first found device, if any.
13//! `format` | A string to customise the output of this block. See below for available placeholders. | <code>\" $icon $name{ $bat_icon $bat_charge\|}{ $notif_icon\|} \"</code>
14//! `format_disconnected` | Same as `format` but when device is disconnected | `" $icon "`
15//! `format_missing` | Same as `format` but when device does not exist | `" $icon x "`
16//! `bat_info` | Min battery level below which state is set to info. | `60`
17//! `bat_good` | Min battery level below which state is set to good. | `60`
18//! `bat_warning` | Min battery level below which state is set to warning. | `30`
19//! `bat_critical` | Min battery level below which state is set to critical. | `15`
20//!
21//! Placeholder        | Value                                                                    | Type   | Unit
22//! -------------------|--------------------------------------------------------------------------|--------|-----
23//! `icon`             | Icon based on connection's status                                        | Icon   | -
24//! `bat_icon`         | Battery level indicator (only when connected and if supported)           | Icon   | -
25//! `bat_charge`       | Battery charge level (only when connected and if supported)              | Number | %
26//! `network_icon`     | Cell Network indicator (only when connected and if supported)            | Icon   | -
27//! `network_type`     | Cell Network type (only when connected and if supported)                 | Text   | -
28//! `network_strength` | Cell Network level (only when connected and if supported)                | Number | %
29//! `notif_icon`       | Only when connected and there are notifications                          | Icon   | -
30//! `notif_count`      | Number of notifications on your phone (only when connected and non-zero) | Number | -
31//! `name`             | Name of your device as reported by KDEConnect (if available)             | Text   | -
32//!
33//! # Example
34//!
35//! Do not show the name, do not set the "good" state.
36//!
37//! ```toml
38//! [[block]]
39//! block = "kdeconnect"
40//! format = " $icon {$bat_icon $bat_charge |}{$notif_icon |}{$network_icon$network_strength $network_type |}"
41//! bat_good = 101
42//! ```
43//!
44//! # Icons Used
45//! - `bat` (as a progression)
46//! - `bat_charging` (as a progression)
47//! - `net_cellular` (as a progression)
48//! - `notification`
49//! - `phone`
50//! - `phone_disconnected`
51
52use super::prelude::*;
53
54mod battery;
55mod connectivity_report;
56use battery::BatteryDbusProxy;
57use connectivity_report::ConnectivityDbusProxy;
58
59make_log_macro!(debug, "kdeconnect");
60
61#[derive(Deserialize, Debug, SmartDefault)]
62#[serde(deny_unknown_fields, default)]
63pub struct Config {
64    pub device_id: Option<String>,
65    pub format: FormatConfig,
66    pub disconnected_format: FormatConfig,
67    pub missing_format: FormatConfig,
68    #[default(60)]
69    pub bat_good: u8,
70    #[default(60)]
71    pub bat_info: u8,
72    #[default(30)]
73    pub bat_warning: u8,
74    #[default(15)]
75    pub bat_critical: u8,
76}
77
78pub async fn run(config: &Config, api: &CommonApi) -> Result<()> {
79    let format = config
80        .format
81        .with_default(" $icon $name {$bat_icon $bat_charge |}{$notif_icon |}")?;
82    let disconnected_format = config.disconnected_format.with_default(" $icon ")?;
83    let missing_format = config.missing_format.with_default(" $icon x ")?;
84
85    let battery_state = (
86        config.bat_good,
87        config.bat_info,
88        config.bat_warning,
89        config.bat_critical,
90    ) != (0, 0, 0, 0);
91
92    let mut monitor = DeviceMonitor::new(config.device_id.clone()).await?;
93
94    loop {
95        match monitor.get_device_info().await {
96            Some(info) => {
97                let mut widget = Widget::new();
98                if info.connected {
99                    widget.set_format(format.clone());
100                } else {
101                    widget.set_format(disconnected_format.clone());
102                }
103
104                let mut values = map! {
105                    [if info.connected] "icon" => Value::icon("phone"),
106                    [if !info.connected] "icon" => Value::icon("phone_disconnected"),
107                    [if let Some(name) = info.name] "name" => Value::text(name),
108                    [if info.notifications > 0] "notif_count" => Value::number(info.notifications),
109                    [if info.notifications > 0] "notif_icon" => Value::icon("notification"),
110                    [if let Some(bat) = info.bat_level] "bat_charge" => Value::percents(bat),
111                };
112
113                if let Some(bat_level) = info.bat_level {
114                    values.insert(
115                        "bat_icon".into(),
116                        Value::icon_progression(
117                            if info.charging { "bat_charging" } else { "bat" },
118                            bat_level as f64 / 100.0,
119                        ),
120                    );
121                    if battery_state {
122                        widget.state = if info.charging {
123                            State::Good
124                        } else if bat_level <= config.bat_critical {
125                            State::Critical
126                        } else if bat_level <= config.bat_info {
127                            State::Info
128                        } else if bat_level > config.bat_good {
129                            State::Good
130                        } else {
131                            State::Idle
132                        };
133                    }
134                }
135
136                if !battery_state {
137                    widget.state = if info.notifications == 0 {
138                        State::Idle
139                    } else {
140                        State::Info
141                    };
142                }
143
144                if let Some(cellular_network_type) = info.cellular_network_type {
145                    // network strength is 0..=4 from docs of
146                    // kdeconnect/plugins/connectivity-report, and I
147                    // got -1 for disabled SIM (undocumented)
148                    let cell_network_percent =
149                        (info.cellular_network_strength.clamp(0, 4) * 25) as f64;
150                    values.insert(
151                        "network_icon".into(),
152                        Value::icon_progression(
153                            "net_cellular",
154                            (info.cellular_network_strength + 1).clamp(0, 5) as f64 / 5.0,
155                        ),
156                    );
157                    values.insert(
158                        "network_strength".into(),
159                        Value::percents(cell_network_percent),
160                    );
161
162                    if info.cellular_network_strength <= 0 {
163                        widget.state = State::Critical;
164                        values.insert("network_type".into(), Value::text("×".into()));
165                    } else {
166                        values.insert("network_type".into(), Value::text(cellular_network_type));
167                    }
168                }
169
170                widget.set_values(values);
171                api.set_widget(widget)?;
172            }
173            None => {
174                let mut widget = Widget::new().with_format(missing_format.clone());
175                widget.set_values(map! { "icon" => Value::icon("phone_disconnected") });
176                api.set_widget(widget)?;
177            }
178        }
179
180        monitor.wait_for_change().await?;
181    }
182}
183
184struct DeviceMonitor {
185    device_id: Option<String>,
186    daemon_proxy: DaemonDbusProxy<'static>,
187    device: Option<Device>,
188}
189
190struct Device {
191    id: String,
192    device_proxy: DeviceDbusProxy<'static>,
193    battery_proxy: BatteryDbusProxy<'static>,
194    notifications_proxy: NotificationsDbusProxy<'static>,
195    connectivity_proxy: ConnectivityDbusProxy<'static>,
196    device_signals: zbus::proxy::SignalStream<'static>,
197    notifications_signals: zbus::proxy::SignalStream<'static>,
198    battery_refreshed: battery::refreshedStream,
199    connectivity_refreshed: connectivity_report::refreshedStream,
200}
201
202struct DeviceInfo {
203    connected: bool,
204    name: Option<String>,
205    notifications: usize,
206    charging: bool,
207    bat_level: Option<u8>,
208    cellular_network_type: Option<String>,
209    cellular_network_strength: i32,
210}
211
212impl DeviceMonitor {
213    async fn new(device_id: Option<String>) -> Result<Self> {
214        let dbus_conn = new_dbus_connection().await?;
215        let daemon_proxy = DaemonDbusProxy::new(&dbus_conn)
216            .await
217            .error("Failed to create DaemonDbusProxy")?;
218        let device = Device::try_find(&daemon_proxy, device_id.as_deref()).await?;
219        Ok(Self {
220            device_id,
221            daemon_proxy,
222            device,
223        })
224    }
225
226    async fn wait_for_change(&mut self) -> Result<()> {
227        match &mut self.device {
228            None => {
229                let mut device_added = self
230                    .daemon_proxy
231                    .receive_device_added()
232                    .await
233                    .error("Couldn't create stream")?;
234                loop {
235                    device_added
236                        .next()
237                        .await
238                        .error("Stream ended unexpectedly")?;
239                    if let Some(device) =
240                        Device::try_find(&self.daemon_proxy, self.device_id.as_deref()).await?
241                    {
242                        self.device = Some(device);
243                        return Ok(());
244                    }
245                }
246            }
247            Some(dev) => {
248                let mut device_removed = self
249                    .daemon_proxy
250                    .receive_device_removed()
251                    .await
252                    .error("Couldn't create stream")?;
253                loop {
254                    select! {
255                        rem = device_removed.next() => {
256                            let rem = rem.error("stream ended unexpectedly")?;
257                            let args = rem.args().error("dbus error")?;
258                            if args.id() == &dev.id {
259                                self.device = Device::try_find(&self.daemon_proxy, self.device_id.as_deref()).await?;
260                                return Ok(());
261                            }
262                        }
263                        _ = dev.wait_for_change() => {
264                            if !dev.connected().await {
265                                debug!("device became unreachable, re-searching");
266                                if let Some(dev) = Device::try_find(&self.daemon_proxy, self.device_id.as_deref()).await? {
267                                    if dev.connected().await {
268                                        debug!("selected {:?}", dev.id);
269                                        self.device = Some(dev);
270                                    }
271                                }
272                            }
273                            return Ok(())
274                        }
275                    }
276                }
277            }
278        }
279    }
280
281    async fn get_device_info(&mut self) -> Option<DeviceInfo> {
282        let device = self.device.as_ref()?;
283        let (bat_level, charging) = device.battery().await;
284        let (cellular_network_type, cellular_network_strength) = device.network().await;
285        Some(DeviceInfo {
286            connected: device.connected().await,
287            name: device.name().await,
288            notifications: device.notifications().await,
289            charging,
290            bat_level,
291            cellular_network_type,
292            cellular_network_strength,
293        })
294    }
295}
296
297impl Device {
298    /// Find a device which `device_id`. Reachable devices have precedence.
299    async fn try_find(
300        daemon_proxy: &DaemonDbusProxy<'_>,
301        device_id: Option<&str>,
302    ) -> Result<Option<Self>> {
303        let Ok(mut devices) = daemon_proxy.devices().await else {
304            debug!("could not get the list of managed objects");
305            return Ok(None);
306        };
307
308        debug!("all devices: {:?}", devices);
309
310        if let Some(device_id) = device_id {
311            devices.retain(|id| id == device_id);
312        }
313
314        let mut selected_device = None;
315
316        for id in devices {
317            let device_proxy = DeviceDbusProxy::builder(daemon_proxy.inner().connection())
318                .cache_properties(zbus::proxy::CacheProperties::No)
319                .path(format!("/modules/kdeconnect/devices/{id}"))
320                .unwrap()
321                .build()
322                .await
323                .error("Failed to create DeviceDbusProxy")?;
324            let reachable = device_proxy.is_reachable().await.unwrap_or(false);
325            selected_device = Some((id, device_proxy));
326            if reachable {
327                break;
328            }
329        }
330
331        let Some((device_id, device_proxy)) = selected_device else {
332            debug!("No device found");
333            return Ok(None);
334        };
335
336        let device_path = format!("/modules/kdeconnect/devices/{device_id}");
337        let battery_path = format!("{device_path}/battery");
338        let notifications_path = format!("{device_path}/notifications");
339        let connectivity_path = format!("{device_path}/connectivity_report");
340
341        let battery_proxy = BatteryDbusProxy::builder(daemon_proxy.inner().connection())
342            .cache_properties(zbus::proxy::CacheProperties::No)
343            .path(battery_path)
344            .error("Failed to set battery path")?
345            .build()
346            .await
347            .error("Failed to create BatteryDbusProxy")?;
348        let notifications_proxy =
349            NotificationsDbusProxy::builder(daemon_proxy.inner().connection())
350                .cache_properties(zbus::proxy::CacheProperties::No)
351                .path(notifications_path)
352                .error("Failed to set notifications path")?
353                .build()
354                .await
355                .error("Failed to create BatteryDbusProxy")?;
356        let connectivity_proxy = ConnectivityDbusProxy::builder(daemon_proxy.inner().connection())
357            .cache_properties(zbus::proxy::CacheProperties::No)
358            .path(connectivity_path)
359            .error("Failed to set connectivity path")?
360            .build()
361            .await
362            .error("Failed to create ConnectivityDbusProxy")?;
363
364        let device_signals = device_proxy
365            .inner()
366            .receive_all_signals()
367            .await
368            .error("Failed to receive signals")?;
369        let notifications_signals = notifications_proxy
370            .inner()
371            .receive_all_signals()
372            .await
373            .error("Failed to receive signals")?;
374        let battery_refreshed = battery_proxy
375            .receive_refreshed()
376            .await
377            .error("Failed to receive signals")?;
378        let connectivity_refreshed = connectivity_proxy
379            .receive_refreshed()
380            .await
381            .error("Failed to receive signals")?;
382
383        Ok(Some(Self {
384            id: device_id,
385            device_proxy,
386            battery_proxy,
387            notifications_proxy,
388            connectivity_proxy,
389            device_signals,
390            notifications_signals,
391            battery_refreshed,
392            connectivity_refreshed,
393        }))
394    }
395
396    async fn wait_for_change(&mut self) {
397        select! {
398            _ = self.device_signals.next() => (),
399            _ = self.notifications_signals.next() => (),
400            _ = self.battery_refreshed.next() => (),
401            _ = self.connectivity_refreshed.next() => (),
402        }
403    }
404
405    async fn connected(&self) -> bool {
406        self.device_proxy.is_reachable().await.unwrap_or(false)
407    }
408
409    async fn name(&self) -> Option<String> {
410        self.device_proxy.name().await.ok()
411    }
412
413    async fn battery(&self) -> (Option<u8>, bool) {
414        let (charge, is_charging) = tokio::join!(
415            self.battery_proxy.charge(),
416            self.battery_proxy.is_charging(),
417        );
418        (
419            charge.ok().map(|x| x.clamp(0, 100) as u8),
420            is_charging.unwrap_or(false),
421        )
422    }
423
424    async fn notifications(&self) -> usize {
425        self.notifications_proxy
426            .active_notifications()
427            .await
428            .map(|n| n.len())
429            .unwrap_or(0)
430    }
431
432    async fn network(&self) -> (Option<String>, i32) {
433        let (ty, strength) = tokio::join!(
434            self.connectivity_proxy.cellular_network_type(),
435            self.connectivity_proxy.cellular_network_strength(),
436        );
437        (ty.ok(), strength.unwrap_or(-1))
438    }
439}
440
441#[zbus::proxy(
442    interface = "org.kde.kdeconnect.daemon",
443    default_service = "org.kde.kdeconnect",
444    default_path = "/modules/kdeconnect"
445)]
446trait DaemonDbus {
447    #[zbus(name = "devices")]
448    fn devices(&self) -> zbus::Result<Vec<String>>;
449
450    #[zbus(signal, name = "deviceAdded")]
451    fn device_added(&self, id: String) -> zbus::Result<()>;
452
453    #[zbus(signal, name = "deviceRemoved")]
454    fn device_removed(&self, id: String) -> zbus::Result<()>;
455}
456
457#[zbus::proxy(
458    interface = "org.kde.kdeconnect.device",
459    default_service = "org.kde.kdeconnect"
460)]
461trait DeviceDbus {
462    #[zbus(property, name = "isReachable")]
463    fn is_reachable(&self) -> zbus::Result<bool>;
464
465    #[zbus(signal, name = "reachableChanged")]
466    fn reachable_changed(&self, reachable: bool) -> zbus::Result<()>;
467
468    #[zbus(property, name = "name")]
469    fn name(&self) -> zbus::Result<String>;
470
471    #[zbus(signal, name = "nameChanged")]
472    fn name_changed_(&self, name: &str) -> zbus::Result<()>;
473}
474
475#[zbus::proxy(
476    interface = "org.kde.kdeconnect.device.notifications",
477    default_service = "org.kde.kdeconnect"
478)]
479trait NotificationsDbus {
480    #[zbus(name = "activeNotifications")]
481    fn active_notifications(&self) -> zbus::Result<Vec<String>>;
482
483    #[zbus(signal, name = "allNotificationsRemoved")]
484    fn all_notifications_removed(&self) -> zbus::Result<()>;
485
486    #[zbus(signal, name = "notificationPosted")]
487    fn notification_posted(&self, id: &str) -> zbus::Result<()>;
488
489    #[zbus(signal, name = "notificationRemoved")]
490    fn notification_removed(&self, id: &str) -> zbus::Result<()>;
491}