Skip to main content

crabshell_services/
bluetooth.rs

1//! Bluetooth adapters and devices from BlueZ, on the system bus.
2
3#[cfg(test)]
4mod tests;
5
6use std::cell::RefCell;
7use std::collections::{BTreeMap, HashMap};
8use std::rc::Rc;
9use std::sync::Once;
10
11use crabshell_reactive::{ReadSignal, Signal};
12use crabshell_ui::Cx;
13use zbus::{Connection, Message, ObjectPath, OwnedObjectPath, Variant};
14
15use crate::Shared;
16use crate::dbus::{self, CALL_TIMEOUT, Daemon, OBJECT_MANAGER, PROPERTIES, Props, set_changed};
17
18const BLUEZ: &str = "org.bluez";
19const ADAPTER: &str = "org.bluez.Adapter1";
20const DEVICE: &str = "org.bluez.Device1";
21const BATTERY: &str = "org.bluez.Battery1";
22
23/// The most adapters shown: one per radio.
24const MAX_ADAPTERS: usize = 16;
25/// The most devices shown: discovery adds every device in range.
26const MAX_DEVICES: usize = 256;
27/// The most objects kept, so a flood can't take the memory.
28const MAX_OBJECTS: usize = 4096;
29/// How long `Pair` and `Connect` may take: BlueZ answers a pairing only
30/// once the user confirmed it in the agent's prompt.
31const SLOW_CALL: std::time::Duration = std::time::Duration::from_secs(90);
32
33/// The properties this service reads, by interface: a change to any
34/// other, such as a device's signal strength during discovery, shows
35/// nothing new.
36fn read(interface: &str, name: &str) -> bool {
37  match interface {
38    ADAPTER => matches!(name, "Alias" | "Address" | "Powered" | "Discovering"),
39    DEVICE => matches!(
40      name,
41      "Address" | "Alias" | "Name" | "Icon" | "Connected" | "Paired" | "Trusted" | "Adapter"
42    ),
43    BATTERY => name == "Percentage",
44    _ => false,
45  }
46}
47
48/// A Bluetooth radio BlueZ knows.
49#[derive(Clone, Debug, PartialEq)]
50pub struct BluetoothAdapter {
51  /// BlueZ's object path for it, such as `/org/bluez/hci0`.
52  pub path: String,
53  /// Its name, BlueZ's `Alias`.
54  pub name: String,
55  /// Its address, such as `00:1A:7D:DA:71:13`.
56  pub address: String,
57  /// Whether it is on.
58  pub powered: bool,
59  /// Whether it is looking for devices.
60  pub discovering: bool,
61}
62
63impl BluetoothAdapter {
64  fn from_props(path: String, props: &Props) -> BluetoothAdapter {
65    BluetoothAdapter {
66      path,
67      name: dbus::text(props, "Alias"),
68      address: dbus::text(props, "Address"),
69      powered: dbus::get(props, "Powered").unwrap_or(false),
70      discovering: dbus::get(props, "Discovering").unwrap_or(false),
71    }
72  }
73}
74
75/// A device BlueZ knows: one paired before, or one discovery found.
76#[derive(Clone, Debug, PartialEq)]
77pub struct BluetoothDevice {
78  /// BlueZ's object path for it, which names it while it exists.
79  pub path: String,
80  /// The path of the adapter it is reached through.
81  pub adapter: String,
82  /// Its address, such as `AA:BB:CC:DD:EE:01`.
83  pub address: String,
84  /// Its `Alias`, else its `Name`, else its address.
85  pub name: String,
86  /// The icon BlueZ suggests, such as `audio-headset`, or `""`.
87  pub icon: String,
88  /// Whether it is connected.
89  pub connected: bool,
90  /// Whether it is paired.
91  pub paired: bool,
92  /// Whether it may connect without asking.
93  pub trusted: bool,
94  /// How full its battery is, from 0 to 100, if it says.
95  pub battery: Option<u8>,
96}
97
98impl BluetoothDevice {
99  fn from_props(path: String, props: &Props, battery: Option<&Props>) -> BluetoothDevice {
100    let address = dbus::text(props, "Address");
101    let name = [dbus::text(props, "Alias"), dbus::text(props, "Name")]
102      .into_iter()
103      .find(|name| !name.is_empty())
104      .unwrap_or_else(|| address.clone());
105    BluetoothDevice {
106      path,
107      adapter: dbus::get::<&ObjectPath>(props, "Adapter")
108        .map_or(String::new(), |at| at.to_string()),
109      address,
110      name,
111      icon: dbus::text(props, "Icon"),
112      connected: dbus::get(props, "Connected").unwrap_or(false),
113      paired: dbus::get(props, "Paired").unwrap_or(false),
114      trusted: dbus::get(props, "Trusted").unwrap_or(false),
115      battery: battery
116        .and_then(|props| dbus::get::<u8>(props, "Percentage"))
117        .map(|percent| percent.min(100)),
118    }
119  }
120}
121
122/// Bluetooth from BlueZ, from `cx.bluetooth()`. Cloning it is cheap.
123#[derive(Clone)]
124pub struct Bluetooth {
125  service: Option<Rc<BluetoothService>>,
126  adapters: ReadSignal<Vec<BluetoothAdapter>>,
127  devices: ReadSignal<Vec<BluetoothDevice>>,
128}
129
130impl Bluetooth {
131  /// Every adapter, in path order. Empty while BlueZ isn't running.
132  pub fn adapters(&self) -> ReadSignal<Vec<BluetoothAdapter>> {
133    self.adapters
134  }
135
136  /// Every device BlueZ knows, in path order. Empty while BlueZ isn't
137  /// running.
138  pub fn devices(&self) -> ReadSignal<Vec<BluetoothDevice>> {
139    self.devices
140  }
141
142  /// Turns the adapter at `adapter`, its path, on or off.
143  pub fn set_powered(&self, adapter: &str, powered: bool) {
144    let body = (ADAPTER, "Powered", Variant::from(powered));
145    self.call(adapter, PROPERTIES, "Set", body);
146  }
147
148  /// Starts looking for devices. Found ones join [`devices`] until BlueZ
149  /// forgets them, a while after [`stop_discovery`].
150  ///
151  /// [`devices`]: Self::devices
152  /// [`stop_discovery`]: Self::stop_discovery
153  pub fn start_discovery(&self, adapter: &str) {
154    self.call(adapter, ADAPTER, "StartDiscovery", ());
155  }
156
157  /// Stops looking for devices.
158  pub fn stop_discovery(&self, adapter: &str) {
159    self.call(adapter, ADAPTER, "StopDiscovery", ());
160  }
161
162  /// Connects the device at `device`, its path.
163  pub fn connect(&self, device: &str) {
164    self.call(device, DEVICE, "Connect", ());
165  }
166
167  /// Disconnects the device at `device`.
168  pub fn disconnect(&self, device: &str) {
169    self.call(device, DEVICE, "Disconnect", ());
170  }
171
172  /// Pairs with the device at `device`. The shell answers no pairing
173  /// questions, so a device that asks for a PIN or a confirmation fails,
174  /// and that is logged, unless another program on the desktop answers
175  /// for BlueZ.
176  pub fn pair(&self, device: &str) {
177    self.call(device, DEVICE, "Pair", ());
178  }
179
180  /// Lets the device at `device` connect without asking, or not.
181  pub fn set_trusted(&self, device: &str, trusted: bool) {
182    let body = (DEVICE, "Trusted", Variant::from(trusted));
183    self.call(device, PROPERTIES, "Set", body);
184  }
185
186  /// Unpairs the device at `device` and has its adapter forget it.
187  pub fn forget(&self, device: &str) {
188    let adapter = self.service.as_ref().and_then(|service| {
189      let state = service.state.borrow();
190      let object = state.as_ref()?.objects.get(device)?;
191      dbus::get::<&ObjectPath>(object.device.as_ref()?, "Adapter").map(|at| at.to_string())
192    });
193    let (Some(adapter), Ok(path)) = (adapter, ObjectPath::try_from(device.to_owned())) else {
194      return tracing::info!(device, "no such Bluetooth device; nothing forgotten");
195    };
196    self.call(&adapter, ADAPTER, "RemoveDevice", path);
197  }
198
199  /// Calls `method` on BlueZ's object at `path`, and logs a refusal.
200  fn call<B>(&self, path: &str, interface: &'static str, method: &'static str, body: B)
201  where
202    B: serde::Serialize + zbus::DynamicType + 'static,
203  {
204    let Some(service) = &self.service else {
205      return;
206    };
207    let Some(connection) = service
208      .state
209      .borrow()
210      .as_ref()
211      .and_then(|state| state.connection.clone())
212    else {
213      return tracing::info!(method, "BlueZ isn't running; nothing sent");
214    };
215    let (shared, path) = (Rc::clone(&service.shared), path.to_owned());
216    service.shared.spawn(async move {
217      let call = connection.call_method(Some(BLUEZ), path.as_str(), Some(interface), method, &body);
218      let limit = if matches!(method, "Pair" | "Connect") {
219        SLOW_CALL
220      } else {
221        CALL_TIMEOUT
222      };
223      if let Err(error) = shared.timeout(limit, call).await {
224        tracing::warn!(%error, path, method, "BlueZ refused it");
225      }
226    });
227  }
228}
229
230/// `cx.bluetooth()`.
231pub(crate) fn bluetooth(cx: &Cx) -> Bluetooth {
232  let Some(service) = cx.service::<BluetoothService>() else {
233    static LOGGED: Once = Once::new();
234    LOGGED.call_once(|| {
235      tracing::warn!("cx.bluetooth() needs the services the shell provides; it stays empty");
236    });
237    return Bluetooth {
238      service: None,
239      adapters: cx.signal(Vec::new()).read_only(),
240      devices: cx.signal(Vec::new()).read_only(),
241    };
242  };
243  service.start()
244}
245
246/// BlueZ for one shell, followed from the first ask.
247pub(crate) struct BluetoothService {
248  shared: Rc<Shared>,
249  state: RefCell<Option<BluetoothState>>,
250}
251
252struct BluetoothState {
253  adapters: Signal<Vec<BluetoothAdapter>>,
254  devices: Signal<Vec<BluetoothDevice>>,
255  /// BlueZ's adapters and devices, by path.
256  objects: BTreeMap<String, Object>,
257  /// The bus, while BlueZ runs, for the methods.
258  connection: Option<Connection>,
259  generation: u64,
260}
261
262/// The interfaces this service reads on one of BlueZ's objects. An
263/// object is kept while it is an adapter or a device.
264#[derive(Default)]
265struct Object {
266  adapter: Option<Props>,
267  device: Option<Props>,
268  battery: Option<Props>,
269}
270
271impl Object {
272  /// Where `interface`'s properties go, or `None` for one this service
273  /// doesn't read.
274  fn slot(&mut self, interface: &str) -> Option<&mut Option<Props>> {
275    match interface {
276      ADAPTER => Some(&mut self.adapter),
277      DEVICE => Some(&mut self.device),
278      BATTERY => Some(&mut self.battery),
279      _ => None,
280    }
281  }
282}
283
284/// Gives the object at `path` `interfaces`. Past [`MAX_OBJECTS`] a new
285/// one is left out; which are shown is decided as they are.
286fn add(objects: &mut BTreeMap<String, Object>, path: String, interfaces: HashMap<String, Props>) {
287  if objects.len() >= MAX_OBJECTS && !objects.contains_key(&path) {
288    return tracing::debug!(path, "too many Bluetooth objects; leaving one out");
289  }
290  let mut object = objects.remove(&path).unwrap_or_default();
291  for (interface, props) in interfaces {
292    if let Some(slot) = object.slot(&interface) {
293      *slot = Some(props);
294    }
295  }
296  if object.adapter.is_some() || object.device.is_some() {
297    objects.insert(path, object);
298  }
299}
300
301impl BluetoothService {
302  pub(crate) fn new(shared: Rc<Shared>) -> BluetoothService {
303    BluetoothService {
304      shared,
305      state: RefCell::new(None),
306    }
307  }
308
309  fn start(self: &Rc<Self>) -> Bluetooth {
310    if let Some(state) = &*self.state.borrow() {
311      return Bluetooth {
312        service: Some(Rc::clone(self)),
313        adapters: state.adapters.read_only(),
314        devices: state.devices.read_only(),
315      };
316    }
317    let root = &self.shared.root;
318    let (adapters, devices) = (root.signal(Vec::new()), root.signal(Vec::new()));
319    *self.state.borrow_mut() = Some(BluetoothState {
320      adapters,
321      devices,
322      objects: BTreeMap::new(),
323      connection: None,
324      generation: 0,
325    });
326    match self.shared.config.system_bus.clone() {
327      Some(address) => {
328        let follow = dbus::follow(Rc::clone(&self.shared), Rc::clone(self), address);
329        self.shared.spawn(follow);
330      }
331      None => tracing::info!("no system bus, so there is no Bluetooth"),
332    }
333    Bluetooth {
334      service: Some(Rc::clone(self)),
335      adapters: adapters.read_only(),
336      devices: devices.read_only(),
337    }
338  }
339
340  /// Shows the adapters and devices as they are now.
341  fn publish(&self) {
342    // Set apart from the borrow, since effects may ask for more.
343    let shown = self.state.borrow().as_ref().map(|state| {
344      let objects = || {
345        state
346          .objects
347          .iter()
348          .map(|(path, object)| (path.clone(), object))
349      };
350      let adapters = objects()
351        .filter_map(|(path, object)| {
352          Some(BluetoothAdapter::from_props(path, object.adapter.as_ref()?))
353        })
354        .take(MAX_ADAPTERS)
355        .collect::<Vec<_>>();
356      let mut devices = objects()
357        .filter_map(|(path, object)| {
358          let device = object.device.as_ref()?;
359          Some(BluetoothDevice::from_props(
360            path,
361            device,
362            object.battery.as_ref(),
363          ))
364        })
365        .collect::<Vec<_>>();
366      // Past the cap, connected and paired ones first, so the devices a
367      // person uses never hide behind strangers discovery found.
368      if devices.len() > MAX_DEVICES {
369        devices.sort_by_key(|device| (!device.connected, !device.paired));
370        devices.truncate(MAX_DEVICES);
371        devices.sort_by(|a, b| a.path.cmp(&b.path));
372      }
373      (state.adapters, adapters, state.devices, devices)
374    });
375    if let Some((adapters_signal, adapters, devices_signal, devices)) = shown {
376      set_changed(adapters_signal, adapters);
377      set_changed(devices_signal, devices);
378    }
379  }
380}
381
382impl Daemon for BluetoothService {
383  const NAME: &'static str = BLUEZ;
384
385  /// Only what adapters and devices are: BlueZ also broadcasts media,
386  /// GATT values and other programs' notifications, which would each
387  /// wake the shell for nothing.
388  fn rules() -> Vec<String> {
389    let mut rules = vec![format!(
390      "type='signal',sender='{BLUEZ}',interface='{OBJECT_MANAGER}'"
391    )];
392    rules.extend([ADAPTER, DEVICE, BATTERY].map(|interface| {
393      format!(
394        "type='signal',sender='{BLUEZ}',interface='{PROPERTIES}',member='PropertiesChanged',\
395         arg0='{interface}'"
396      )
397    }));
398    rules
399  }
400
401  fn alive(&self) -> bool {
402    let state = self.state.borrow();
403    state
404      .as_ref()
405      .is_some_and(|state| state.adapters.try_with(|_| ()).is_some())
406  }
407
408  fn clear(&self) -> u64 {
409    let generation = {
410      let mut state = self.state.borrow_mut();
411      let Some(state) = state.as_mut() else {
412        return 0;
413      };
414      state.objects.clear();
415      state.connection = None;
416      state.generation += 1;
417      state.generation
418    };
419    self.publish();
420    generation
421  }
422
423  async fn read(self: Rc<Self>, connection: Connection, generation: u64) {
424    let call = connection.call_method(
425      Some(BLUEZ),
426      "/",
427      Some(OBJECT_MANAGER),
428      "GetManagedObjects",
429      &(),
430    );
431    let read = async {
432      let reply = self.shared.timeout(CALL_TIMEOUT, call).await?;
433      reply
434        .body()
435        .deserialize::<HashMap<OwnedObjectPath, HashMap<String, Props>>>()
436    };
437    let managed = match read.await {
438      Ok(managed) => managed,
439      Err(error) => return tracing::warn!(%error, "couldn't read BlueZ"),
440    };
441    // In path order, so the caps keep the same objects every time.
442    let managed = managed
443      .into_iter()
444      .map(|(path, interfaces)| (path.as_str().to_owned(), interfaces))
445      .collect::<BTreeMap<_, _>>();
446    {
447      let mut state = self.state.borrow_mut();
448      let Some(state) = state
449        .as_mut()
450        .filter(|state| state.generation == generation)
451      else {
452        return;
453      };
454      state.objects.clear();
455      for (path, interfaces) in managed {
456        add(&mut state.objects, path, interfaces);
457      }
458      state.connection = Some(connection);
459    }
460    self.publish();
461  }
462
463  fn signal(self: &Rc<Self>, _: &Connection, message: &Message) {
464    if let Some((path, interface, changed, dropped)) = dbus::properties_changed(message) {
465      {
466        let mut state = self.state.borrow_mut();
467        let props = state
468          .as_mut()
469          .and_then(|state| state.objects.get_mut(&path))
470          .and_then(|object| object.slot(&interface)?.as_mut());
471        let Some(props) = props else {
472          return;
473        };
474        let shown = changed
475          .keys()
476          .chain(&dropped)
477          .any(|name| read(&interface, name));
478        dbus::apply(props, changed, dropped);
479        if !shown {
480          return;
481        }
482      }
483      return self.publish();
484    }
485    let header = message.header();
486    if header.interface().map(|interface| interface.as_str()) != Some(OBJECT_MANAGER) {
487      return;
488    }
489    let body = message.body();
490    {
491      let mut state = self.state.borrow_mut();
492      let Some(state) = state.as_mut() else {
493        return;
494      };
495      match header.member().map(|member| member.as_str()) {
496        Some("InterfacesAdded") => {
497          let Ok((path, interfaces)) =
498            body.deserialize::<(OwnedObjectPath, HashMap<String, Props>)>()
499          else {
500            return;
501          };
502          add(&mut state.objects, path.as_str().to_owned(), interfaces);
503        }
504        Some("InterfacesRemoved") => {
505          let Ok((path, interfaces)) = body.deserialize::<(OwnedObjectPath, Vec<String>)>() else {
506            return;
507          };
508          let Some(object) = state.objects.get_mut(path.as_str()) else {
509            return;
510          };
511          for interface in interfaces {
512            if let Some(slot) = object.slot(&interface) {
513              *slot = None;
514            }
515          }
516          if object.adapter.is_none() && object.device.is_none() {
517            state.objects.remove(path.as_str());
518          }
519        }
520        _ => return,
521      }
522    }
523    self.publish();
524  }
525}