Skip to main content

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` (`$bat_icon`, as a progression)
46//! - `bat_charging` (`$bat_icon`, as a progression)
47//! - `net_cellular` (`$network_icon`, as a progression)
48//! - `notification` (`$notif_icon`)
49//! - `phone` (`$icon`, in `format`)
50//! - `phone_disconnected` (`$icon`, in `disconnected_format` `missing_format`)
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(crate) fn prepare(config: &Config) -> Result<Arc<BlockPlan>> {
79    BlockPlan::new(vec![
80        OutputPlan::new(
81            "connected",
82            config
83                .format
84                .with_default(" $icon $name {$bat_icon $bat_charge |}{$notif_icon |}")?,
85        )
86        .icon("icon", IconChoices::one(icons::PHONE))
87        .icon(
88            "bat_icon",
89            IconChoices::fixed([icons::BAT, icons::BAT_CHARGING]),
90        )
91        .icon("network_icon", IconChoices::one(icons::NET_CELLULAR))
92        .icon("notif_icon", IconChoices::one(icons::NOTIFICATION)),
93        OutputPlan::new(
94            "disconnected",
95            config.disconnected_format.with_default(" $icon ")?,
96        )
97        .icon("icon", IconChoices::one(icons::PHONE_DISCONNECTED))
98        .icon(
99            "bat_icon",
100            IconChoices::fixed([icons::BAT, icons::BAT_CHARGING]),
101        )
102        .icon("network_icon", IconChoices::one(icons::NET_CELLULAR))
103        .icon("notif_icon", IconChoices::one(icons::NOTIFICATION)),
104        OutputPlan::new("missing", config.missing_format.with_default(" $icon x ")?)
105            .icon("icon", IconChoices::one(icons::PHONE_DISCONNECTED)),
106    ])
107}
108
109pub(crate) async fn run(config: &Config, api: &CommonApi, plan: &Arc<BlockPlan>) -> Result<()> {
110    let output_connected = plan.output("connected")?;
111    let output_disconnected = plan.output("disconnected")?;
112    let output_missing = plan.output("missing")?;
113
114    let battery_state = (
115        config.bat_good,
116        config.bat_info,
117        config.bat_warning,
118        config.bat_critical,
119    ) != (0, 0, 0, 0);
120
121    let mut monitor = DeviceMonitor::new(config.device_id.clone()).await?;
122
123    loop {
124        match monitor.get_device_info().await {
125            Some(info) => {
126                let output = if info.connected {
127                    &output_connected
128                } else {
129                    &output_disconnected
130                };
131                let mut widget = output.new_widget();
132
133                let mut values = map! {
134                    "icon" => output.icon_value("icon")?,
135                    [if let Some(name) = info.name] "name" => Value::text(name),
136                    [if info.notifications > 0] "notif_count" => Value::number(info.notifications),
137                    [if info.notifications > 0] "notif_icon" => Value::icon(icons::NOTIFICATION),
138                    [if let Some(bat) = info.bat_level] "bat_charge" => Value::percents(bat),
139                };
140
141                if let Some(bat_level) = info.bat_level {
142                    values.insert(
143                        "bat_icon".into(),
144                        Value::icon_progression(
145                            if info.charging {
146                                icons::BAT_CHARGING
147                            } else {
148                                icons::BAT
149                            },
150                            bat_level as f64 / 100.0,
151                        ),
152                    );
153                    if battery_state {
154                        widget.state = if info.charging {
155                            State::Good
156                        } else if bat_level <= config.bat_critical {
157                            State::Critical
158                        } else if bat_level <= config.bat_info {
159                            State::Info
160                        } else if bat_level > config.bat_good {
161                            State::Good
162                        } else {
163                            State::Idle
164                        };
165                    }
166                }
167
168                if !battery_state {
169                    widget.state = if info.notifications == 0 {
170                        State::Idle
171                    } else {
172                        State::Info
173                    };
174                }
175
176                if let Some(cellular_network_type) = info.cellular_network_type {
177                    // network strength is 0..=4 from docs of
178                    // kdeconnect/plugins/connectivity-report, and I
179                    // got -1 for disabled SIM (undocumented)
180                    let cell_network_percent =
181                        (info.cellular_network_strength.clamp(0, 4) * 25) as f64;
182                    values.insert(
183                        "network_icon".into(),
184                        Value::icon_progression(
185                            icons::NET_CELLULAR,
186                            (info.cellular_network_strength + 1).clamp(0, 5) as f64 / 5.0,
187                        ),
188                    );
189                    values.insert(
190                        "network_strength".into(),
191                        Value::percents(cell_network_percent),
192                    );
193
194                    if info.cellular_network_strength <= 0 {
195                        widget.state = State::Critical;
196                        values.insert("network_type".into(), Value::text("×".into()));
197                    } else {
198                        values.insert("network_type".into(), Value::text(cellular_network_type));
199                    }
200                }
201
202                widget.set_values(values);
203                api.set_widget(widget)?;
204            }
205            None => {
206                let mut widget = output_missing.new_widget();
207                widget.set_values(map! { "icon" => Value::icon(icons::PHONE_DISCONNECTED) });
208                api.set_widget(widget)?;
209            }
210        }
211
212        monitor.wait_for_change().await?;
213    }
214}
215
216struct DeviceMonitor {
217    device_id: Option<String>,
218    daemon_proxy: DaemonDbusProxy<'static>,
219    device: Option<Device>,
220}
221
222struct Device {
223    id: String,
224    device_proxy: DeviceDbusProxy<'static>,
225    battery_proxy: BatteryDbusProxy<'static>,
226    notifications_proxy: NotificationsDbusProxy<'static>,
227    connectivity_proxy: ConnectivityDbusProxy<'static>,
228    device_signals: zbus::proxy::SignalStream<'static>,
229    notifications_signals: zbus::proxy::SignalStream<'static>,
230    battery_refreshed: battery::refreshedStream,
231    connectivity_refreshed: connectivity_report::refreshedStream,
232}
233
234struct DeviceInfo {
235    connected: bool,
236    name: Option<String>,
237    notifications: usize,
238    charging: bool,
239    bat_level: Option<u8>,
240    cellular_network_type: Option<String>,
241    cellular_network_strength: i32,
242}
243
244impl DeviceMonitor {
245    async fn new(device_id: Option<String>) -> Result<Self> {
246        let dbus_conn = new_dbus_connection().await?;
247        let daemon_proxy = DaemonDbusProxy::new(&dbus_conn)
248            .await
249            .error("Failed to create DaemonDbusProxy")?;
250        let device = Device::try_find(&daemon_proxy, device_id.as_deref()).await?;
251        Ok(Self {
252            device_id,
253            daemon_proxy,
254            device,
255        })
256    }
257
258    async fn wait_for_change(&mut self) -> Result<()> {
259        match &mut self.device {
260            None => {
261                let mut device_added = self
262                    .daemon_proxy
263                    .receive_device_added()
264                    .await
265                    .error("Couldn't create stream")?;
266                loop {
267                    device_added
268                        .next()
269                        .await
270                        .error("Stream ended unexpectedly")?;
271                    if let Some(device) =
272                        Device::try_find(&self.daemon_proxy, self.device_id.as_deref()).await?
273                    {
274                        self.device = Some(device);
275                        return Ok(());
276                    }
277                }
278            }
279            Some(dev) => {
280                let mut device_removed = self
281                    .daemon_proxy
282                    .receive_device_removed()
283                    .await
284                    .error("Couldn't create stream")?;
285                loop {
286                    select! {
287                        rem = device_removed.next() => {
288                            let rem = rem.error("stream ended unexpectedly")?;
289                            let args = rem.args().error("dbus error")?;
290                            if args.id() == &dev.id {
291                                self.device = Device::try_find(&self.daemon_proxy, self.device_id.as_deref()).await?;
292                                return Ok(());
293                            }
294                        }
295                        _ = dev.wait_for_change() => {
296                            if !dev.connected().await {
297                                debug!("device became unreachable, re-searching");
298                                if let Some(dev) = Device::try_find(&self.daemon_proxy, self.device_id.as_deref()).await?
299                                    && dev.connected().await {
300                                        debug!("selected {:?}", dev.id);
301                                        self.device = Some(dev);
302                                    }
303                            }
304                            return Ok(())
305                        }
306                    }
307                }
308            }
309        }
310    }
311
312    async fn get_device_info(&mut self) -> Option<DeviceInfo> {
313        let device = self.device.as_ref()?;
314        let (bat_level, charging) = device.battery().await;
315        let (cellular_network_type, cellular_network_strength) = device.network().await;
316        Some(DeviceInfo {
317            connected: device.connected().await,
318            name: device.name().await,
319            notifications: device.notifications().await,
320            charging,
321            bat_level,
322            cellular_network_type,
323            cellular_network_strength,
324        })
325    }
326}
327
328impl Device {
329    /// Find a device which `device_id`. Reachable devices have precedence.
330    async fn try_find(
331        daemon_proxy: &DaemonDbusProxy<'_>,
332        device_id: Option<&str>,
333    ) -> Result<Option<Self>> {
334        let Ok(mut devices) = daemon_proxy.devices().await else {
335            debug!("could not get the list of managed objects");
336            return Ok(None);
337        };
338
339        debug!("all devices: {:?}", devices);
340
341        if let Some(device_id) = device_id {
342            devices.retain(|id| id == device_id);
343        }
344
345        let mut selected_device = None;
346
347        for id in devices {
348            let device_proxy = DeviceDbusProxy::builder(daemon_proxy.inner().connection())
349                .cache_properties(zbus::proxy::CacheProperties::No)
350                .path(format!("/modules/kdeconnect/devices/{id}"))
351                .unwrap()
352                .build()
353                .await
354                .error("Failed to create DeviceDbusProxy")?;
355            let reachable = device_proxy.is_reachable().await.unwrap_or(false);
356            selected_device = Some((id, device_proxy));
357            if reachable {
358                break;
359            }
360        }
361
362        let Some((device_id, device_proxy)) = selected_device else {
363            debug!("No device found");
364            return Ok(None);
365        };
366
367        let device_path = format!("/modules/kdeconnect/devices/{device_id}");
368        let battery_path = format!("{device_path}/battery");
369        let notifications_path = format!("{device_path}/notifications");
370        let connectivity_path = format!("{device_path}/connectivity_report");
371
372        let battery_proxy = BatteryDbusProxy::builder(daemon_proxy.inner().connection())
373            .cache_properties(zbus::proxy::CacheProperties::No)
374            .path(battery_path)
375            .error("Failed to set battery path")?
376            .build()
377            .await
378            .error("Failed to create BatteryDbusProxy")?;
379        let notifications_proxy =
380            NotificationsDbusProxy::builder(daemon_proxy.inner().connection())
381                .cache_properties(zbus::proxy::CacheProperties::No)
382                .path(notifications_path)
383                .error("Failed to set notifications path")?
384                .build()
385                .await
386                .error("Failed to create BatteryDbusProxy")?;
387        let connectivity_proxy = ConnectivityDbusProxy::builder(daemon_proxy.inner().connection())
388            .cache_properties(zbus::proxy::CacheProperties::No)
389            .path(connectivity_path)
390            .error("Failed to set connectivity path")?
391            .build()
392            .await
393            .error("Failed to create ConnectivityDbusProxy")?;
394
395        let device_signals = device_proxy
396            .inner()
397            .receive_all_signals()
398            .await
399            .error("Failed to receive signals")?;
400        let notifications_signals = notifications_proxy
401            .inner()
402            .receive_all_signals()
403            .await
404            .error("Failed to receive signals")?;
405        let battery_refreshed = battery_proxy
406            .receive_refreshed()
407            .await
408            .error("Failed to receive signals")?;
409        let connectivity_refreshed = connectivity_proxy
410            .receive_refreshed()
411            .await
412            .error("Failed to receive signals")?;
413
414        Ok(Some(Self {
415            id: device_id,
416            device_proxy,
417            battery_proxy,
418            notifications_proxy,
419            connectivity_proxy,
420            device_signals,
421            notifications_signals,
422            battery_refreshed,
423            connectivity_refreshed,
424        }))
425    }
426
427    async fn wait_for_change(&mut self) {
428        select! {
429            _ = self.device_signals.next() => (),
430            _ = self.notifications_signals.next() => (),
431            _ = self.battery_refreshed.next() => (),
432            _ = self.connectivity_refreshed.next() => (),
433        }
434    }
435
436    async fn connected(&self) -> bool {
437        self.device_proxy.is_reachable().await.unwrap_or(false)
438    }
439
440    async fn name(&self) -> Option<String> {
441        self.device_proxy.name().await.ok()
442    }
443
444    async fn battery(&self) -> (Option<u8>, bool) {
445        let (charge, is_charging) = tokio::join!(
446            self.battery_proxy.charge(),
447            self.battery_proxy.is_charging(),
448        );
449        (
450            charge.ok().map(|x| x.clamp(0, 100) as u8),
451            is_charging.unwrap_or(false),
452        )
453    }
454
455    async fn notifications(&self) -> usize {
456        self.notifications_proxy
457            .active_notifications()
458            .await
459            .map(|n| n.len())
460            .unwrap_or(0)
461    }
462
463    async fn network(&self) -> (Option<String>, i32) {
464        let (ty, strength) = tokio::join!(
465            self.connectivity_proxy.cellular_network_type(),
466            self.connectivity_proxy.cellular_network_strength(),
467        );
468        (ty.ok(), strength.unwrap_or(-1))
469    }
470}
471
472#[zbus::proxy(
473    interface = "org.kde.kdeconnect.daemon",
474    default_service = "org.kde.kdeconnect",
475    default_path = "/modules/kdeconnect"
476)]
477trait DaemonDbus {
478    #[zbus(name = "devices")]
479    fn devices(&self) -> zbus::Result<Vec<String>>;
480
481    #[zbus(signal, name = "deviceAdded")]
482    fn device_added(&self, id: String) -> zbus::Result<()>;
483
484    #[zbus(signal, name = "deviceRemoved")]
485    fn device_removed(&self, id: String) -> zbus::Result<()>;
486}
487
488#[zbus::proxy(
489    interface = "org.kde.kdeconnect.device",
490    default_service = "org.kde.kdeconnect"
491)]
492trait DeviceDbus {
493    #[zbus(property, name = "isReachable")]
494    fn is_reachable(&self) -> zbus::Result<bool>;
495
496    #[zbus(signal, name = "reachableChanged")]
497    fn reachable_changed(&self, reachable: bool) -> zbus::Result<()>;
498
499    #[zbus(property, name = "name")]
500    fn name(&self) -> zbus::Result<String>;
501
502    #[zbus(signal, name = "nameChanged")]
503    fn name_changed_(&self, name: &str) -> zbus::Result<()>;
504}
505
506#[zbus::proxy(
507    interface = "org.kde.kdeconnect.device.notifications",
508    default_service = "org.kde.kdeconnect"
509)]
510trait NotificationsDbus {
511    #[zbus(name = "activeNotifications")]
512    fn active_notifications(&self) -> zbus::Result<Vec<String>>;
513
514    #[zbus(signal, name = "allNotificationsRemoved")]
515    fn all_notifications_removed(&self) -> zbus::Result<()>;
516
517    #[zbus(signal, name = "notificationPosted")]
518    fn notification_posted(&self, id: &str) -> zbus::Result<()>;
519
520    #[zbus(signal, name = "notificationRemoved")]
521    fn notification_removed(&self, id: &str) -> zbus::Result<()>;
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527
528    #[test]
529    fn plan_declares_connection_states_and_icons() {
530        let plan = prepare(&Config::default()).unwrap();
531        let ids: Vec<_> = plan.outputs().map(|o| o.id()).collect();
532        assert_eq!(ids, ["connected", "disconnected", "missing"]);
533
534        let connected = plan.output("connected").unwrap();
535        assert_eq!(connected.single_icon("icon").unwrap(), "phone");
536        let bat = connected.output().choices_for("bat_icon").unwrap();
537        assert!(bat.permits("bat") && bat.permits("bat_charging"));
538        assert!(!bat.permits("phone"));
539
540        for id in ["disconnected", "missing"] {
541            let output = plan.output(id).unwrap();
542            assert_eq!(output.single_icon("icon").unwrap(), "phone_disconnected");
543        }
544        // `missing` renders only `$icon`; the battery/network/notification
545        // values are never set in that state.
546        assert!(
547            plan.output("missing")
548                .unwrap()
549                .output()
550                .choices_for("bat_icon")
551                .is_none()
552        );
553    }
554}