1use 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
82const 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 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 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 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, };
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 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 .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 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 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}