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!(
39      name,
40      "Alias" | "Address" | "Powered" | "PowerState" | "Discovering"
41    ),
42    DEVICE => matches!(
43      name,
44      "Address" | "Alias" | "Name" | "Icon" | "Connected" | "Paired" | "Trusted" | "Adapter"
45    ),
46    BATTERY => name == "Percentage",
47    _ => false,
48  }
49}
50
51/// Whether an adapter is on, from BlueZ's `PowerState`.
52#[derive(Clone, Copy, Debug, PartialEq, Eq)]
53pub enum BluetoothAdapterState {
54  /// On.
55  Enabled,
56  /// Off.
57  Disabled,
58  /// Turning on.
59  Enabling,
60  /// Turning off.
61  Disabling,
62  /// Off, and held off by rfkill, as by a laptop's airplane mode.
63  Blocked,
64}
65
66/// Whether a device is connected, from BlueZ's `Connected` and the calls
67/// made through [`Bluetooth`].
68#[derive(Clone, Copy, Debug, PartialEq, Eq)]
69pub enum BluetoothDeviceState {
70  /// Not connected.
71  Disconnected,
72  /// A [`Bluetooth::connect`] call is out.
73  Connecting,
74  /// Connected.
75  Connected,
76  /// A [`Bluetooth::disconnect`] call is out.
77  Disconnecting,
78}
79
80/// A Bluetooth radio BlueZ knows.
81#[derive(Clone, Debug, PartialEq)]
82pub struct BluetoothAdapter {
83  /// BlueZ's object path for it, such as `/org/bluez/hci0`.
84  pub path: String,
85  /// Its name, BlueZ's `Alias`.
86  pub name: String,
87  /// Its address, such as `00:1A:7D:DA:71:13`.
88  pub address: String,
89  /// Whether it is on.
90  pub powered: bool,
91  /// Whether it is on, off or on the way. An older BlueZ, without
92  /// `PowerState`, says only `Enabled` or `Disabled`, from
93  /// [`powered`](Self::powered).
94  pub state: BluetoothAdapterState,
95  /// Whether it is looking for devices.
96  pub discovering: bool,
97}
98
99impl BluetoothAdapter {
100  fn from_props(path: String, props: &Props) -> BluetoothAdapter {
101    let powered = dbus::get(props, "Powered").unwrap_or(false);
102    let state = match dbus::get::<&str>(props, "PowerState") {
103      Some("on") => BluetoothAdapterState::Enabled,
104      Some("off") => BluetoothAdapterState::Disabled,
105      Some("off-enabling") => BluetoothAdapterState::Enabling,
106      Some("on-disabling") => BluetoothAdapterState::Disabling,
107      Some("off-blocked") => BluetoothAdapterState::Blocked,
108      unknown => {
109        if let Some(unknown) = unknown {
110          tracing::debug!(
111            path,
112            state = unknown,
113            "an unknown power state; read from Powered"
114          );
115        }
116        if powered {
117          BluetoothAdapterState::Enabled
118        } else {
119          BluetoothAdapterState::Disabled
120        }
121      }
122    };
123    BluetoothAdapter {
124      name: dbus::text(props, "Alias"),
125      address: dbus::text(props, "Address"),
126      powered,
127      state,
128      discovering: dbus::get(props, "Discovering").unwrap_or(false),
129      path,
130    }
131  }
132}
133
134/// A device BlueZ knows: one paired before, or one discovery found.
135#[derive(Clone, Debug, PartialEq)]
136pub struct BluetoothDevice {
137  /// BlueZ's object path for it, which names it while it exists.
138  pub path: String,
139  /// The path of the adapter it is reached through.
140  pub adapter: String,
141  /// Its address, such as `AA:BB:CC:DD:EE:01`.
142  pub address: String,
143  /// Its `Alias`, else its `Name`, else its address.
144  pub name: String,
145  /// The icon BlueZ suggests, such as `audio-headset`, or `""`.
146  pub icon: String,
147  /// Whether it is connected.
148  pub connected: bool,
149  /// Whether it is connected, or on the way while a call made through
150  /// [`Bluetooth`] is out.
151  pub state: BluetoothDeviceState,
152  /// Whether it is paired.
153  pub paired: bool,
154  /// Whether it may connect without asking.
155  pub trusted: bool,
156  /// How full its battery is, from 0 to 100, if it says.
157  pub battery: Option<u8>,
158}
159
160impl BluetoothDevice {
161  fn from_props(
162    path: String,
163    props: &Props,
164    battery: Option<&Props>,
165    call: Option<BluetoothDeviceState>,
166  ) -> BluetoothDevice {
167    let address = dbus::text(props, "Address");
168    let connected = dbus::get(props, "Connected").unwrap_or(false);
169    let state = match (call, connected) {
170      (Some(call), _) => call,
171      (None, true) => BluetoothDeviceState::Connected,
172      (None, false) => BluetoothDeviceState::Disconnected,
173    };
174    let name = [dbus::text(props, "Alias"), dbus::text(props, "Name")]
175      .into_iter()
176      .find(|name| !name.is_empty())
177      .unwrap_or_else(|| address.clone());
178    BluetoothDevice {
179      path,
180      adapter: dbus::get::<&ObjectPath>(props, "Adapter")
181        .map_or(String::new(), |at| at.to_string()),
182      address,
183      name,
184      icon: dbus::text(props, "Icon"),
185      connected,
186      state,
187      paired: dbus::get(props, "Paired").unwrap_or(false),
188      trusted: dbus::get(props, "Trusted").unwrap_or(false),
189      battery: battery
190        .and_then(|props| dbus::get::<u8>(props, "Percentage"))
191        .map(|percent| percent.min(100)),
192    }
193  }
194}
195
196/// Bluetooth from BlueZ, from `cx.bluetooth()`. Cloning it is cheap.
197#[derive(Clone)]
198pub struct Bluetooth {
199  service: Option<Rc<BluetoothService>>,
200  adapters: ReadSignal<Vec<BluetoothAdapter>>,
201  devices: ReadSignal<Vec<BluetoothDevice>>,
202}
203
204impl Bluetooth {
205  /// Every adapter, in path order. Empty while BlueZ isn't running.
206  pub fn adapters(&self) -> ReadSignal<Vec<BluetoothAdapter>> {
207    self.adapters
208  }
209
210  /// Every device BlueZ knows, in path order. Empty while BlueZ isn't
211  /// running.
212  pub fn devices(&self) -> ReadSignal<Vec<BluetoothDevice>> {
213    self.devices
214  }
215
216  /// Turns the adapter at `adapter`, its path, on or off.
217  pub fn set_powered(&self, adapter: &str, powered: bool) {
218    let body = (ADAPTER, "Powered", Variant::from(powered));
219    self.call(adapter, PROPERTIES, "Set", body);
220  }
221
222  /// Starts looking for devices. Found ones join [`devices`] until BlueZ
223  /// forgets them, a while after [`stop_discovery`].
224  ///
225  /// [`devices`]: Self::devices
226  /// [`stop_discovery`]: Self::stop_discovery
227  pub fn start_discovery(&self, adapter: &str) {
228    self.call(adapter, ADAPTER, "StartDiscovery", ());
229  }
230
231  /// Stops looking for devices.
232  pub fn stop_discovery(&self, adapter: &str) {
233    self.call(adapter, ADAPTER, "StopDiscovery", ());
234  }
235
236  /// Connects the device at `device`, its path. Its state is
237  /// [`Connecting`](BluetoothDeviceState::Connecting) until BlueZ answers
238  /// or says it connected or disconnected.
239  pub fn connect(&self, device: &str) {
240    self.call(device, DEVICE, "Connect", ());
241  }
242
243  /// Disconnects the device at `device`, which is
244  /// [`Disconnecting`](BluetoothDeviceState::Disconnecting) as
245  /// [`connect`](Self::connect) is connecting.
246  pub fn disconnect(&self, device: &str) {
247    self.call(device, DEVICE, "Disconnect", ());
248  }
249
250  /// Pairs with the device at `device`. The shell answers no pairing
251  /// questions, so a device that asks for a PIN or a confirmation fails,
252  /// and that is logged, unless another program on the desktop answers
253  /// for BlueZ.
254  pub fn pair(&self, device: &str) {
255    self.call(device, DEVICE, "Pair", ());
256  }
257
258  /// Lets the device at `device` connect without asking, or not.
259  pub fn set_trusted(&self, device: &str, trusted: bool) {
260    let body = (DEVICE, "Trusted", Variant::from(trusted));
261    self.call(device, PROPERTIES, "Set", body);
262  }
263
264  /// Unpairs the device at `device` and has its adapter forget it.
265  pub fn forget(&self, device: &str) {
266    let adapter = self.service.as_ref().and_then(|service| {
267      let state = service.state.borrow();
268      let object = state.as_ref()?.objects.get(device)?;
269      dbus::get::<&ObjectPath>(object.device.as_ref()?, "Adapter").map(|at| at.to_string())
270    });
271    let (Some(adapter), Ok(path)) = (adapter, ObjectPath::try_from(device.to_owned())) else {
272      return tracing::info!(device, "no such Bluetooth device; nothing forgotten");
273    };
274    self.call(&adapter, ADAPTER, "RemoveDevice", path);
275  }
276
277  /// Calls `method` on BlueZ's object at `path`, and logs a refusal.
278  fn call<B>(&self, path: &str, interface: &'static str, method: &'static str, body: B)
279  where
280    B: serde::Serialize + zbus::DynamicType + 'static,
281  {
282    let Some(service) = &self.service else {
283      return;
284    };
285    let Some(connection) = service
286      .state
287      .borrow()
288      .as_ref()
289      .and_then(|state| state.connection.clone())
290    else {
291      return tracing::info!(method, "BlueZ isn't running; nothing sent");
292    };
293    let marked = match method {
294      "Connect" => Some(service.mark(path, BluetoothDeviceState::Connecting)),
295      "Disconnect" => Some(service.mark(path, BluetoothDeviceState::Disconnecting)),
296      _ => None,
297    };
298    let (shared, service, path) = (&service.shared, Rc::clone(service), path.to_owned());
299    shared.spawn(async move {
300      let call = connection.call_method(Some(BLUEZ), path.as_str(), Some(interface), method, &body);
301      let limit = if matches!(method, "Pair" | "Connect") {
302        SLOW_CALL
303      } else {
304        CALL_TIMEOUT
305      };
306      let answer = service.shared.timeout(limit, call).await;
307      if let Some(number) = marked {
308        service.unmark(&path, number);
309      }
310      if let Err(error) = answer {
311        tracing::warn!(%error, path, method, "BlueZ refused it");
312      }
313    });
314  }
315}
316
317/// `cx.bluetooth()`.
318pub(crate) fn bluetooth(cx: &Cx) -> Bluetooth {
319  let Some(service) = cx.service::<BluetoothService>() else {
320    static LOGGED: Once = Once::new();
321    LOGGED.call_once(|| {
322      tracing::warn!("cx.bluetooth() needs the services the shell provides; it stays empty");
323    });
324    return Bluetooth {
325      service: None,
326      adapters: cx.signal(Vec::new()).read_only(),
327      devices: cx.signal(Vec::new()).read_only(),
328    };
329  };
330  service.start()
331}
332
333/// BlueZ for one shell, followed from the first ask.
334pub(crate) struct BluetoothService {
335  shared: Rc<Shared>,
336  state: RefCell<Option<BluetoothState>>,
337}
338
339struct BluetoothState {
340  adapters: Signal<Vec<BluetoothAdapter>>,
341  devices: Signal<Vec<BluetoothDevice>>,
342  /// BlueZ's adapters and devices, by path.
343  objects: BTreeMap<String, Object>,
344  /// The bus, while BlueZ runs, for the methods.
345  connection: Option<Connection>,
346  generation: u64,
347  /// The devices a connect or disconnect call is out for, by path, with
348  /// the call's number, so the answer to one that another replaced
349  /// changes nothing.
350  calls: HashMap<String, (BluetoothDeviceState, u64)>,
351  /// The number the next such call gets.
352  next_call: u64,
353}
354
355/// The interfaces this service reads on one of BlueZ's objects. An
356/// object is kept while it is an adapter or a device.
357#[derive(Default)]
358struct Object {
359  adapter: Option<Props>,
360  device: Option<Props>,
361  battery: Option<Props>,
362}
363
364impl Object {
365  /// Where `interface`'s properties go, or `None` for one this service
366  /// doesn't read.
367  fn slot(&mut self, interface: &str) -> Option<&mut Option<Props>> {
368    match interface {
369      ADAPTER => Some(&mut self.adapter),
370      DEVICE => Some(&mut self.device),
371      BATTERY => Some(&mut self.battery),
372      _ => None,
373    }
374  }
375}
376
377/// Gives the object at `path` `interfaces`. Past [`MAX_OBJECTS`] a new
378/// one is left out; which are shown is decided as they are.
379fn add(objects: &mut BTreeMap<String, Object>, path: String, interfaces: HashMap<String, Props>) {
380  if objects.len() >= MAX_OBJECTS && !objects.contains_key(&path) {
381    return tracing::debug!(path, "too many Bluetooth objects; leaving one out");
382  }
383  let mut object = objects.remove(&path).unwrap_or_default();
384  for (interface, props) in interfaces {
385    if let Some(slot) = object.slot(&interface) {
386      *slot = Some(props);
387    }
388  }
389  if object.adapter.is_some() || object.device.is_some() {
390    objects.insert(path, object);
391  }
392}
393
394impl BluetoothService {
395  pub(crate) fn new(shared: Rc<Shared>) -> BluetoothService {
396    BluetoothService {
397      shared,
398      state: RefCell::new(None),
399    }
400  }
401
402  fn start(self: &Rc<Self>) -> Bluetooth {
403    if let Some(state) = &*self.state.borrow() {
404      return Bluetooth {
405        service: Some(Rc::clone(self)),
406        adapters: state.adapters.read_only(),
407        devices: state.devices.read_only(),
408      };
409    }
410    let root = &self.shared.root;
411    let (adapters, devices) = (root.signal(Vec::new()), root.signal(Vec::new()));
412    *self.state.borrow_mut() = Some(BluetoothState {
413      adapters,
414      devices,
415      objects: BTreeMap::new(),
416      connection: None,
417      generation: 0,
418      calls: HashMap::new(),
419      next_call: 0,
420    });
421    match self.shared.config.system_bus.clone() {
422      Some(address) => {
423        let follow = dbus::follow(Rc::clone(&self.shared), Rc::clone(self), address);
424        self.shared.spawn(follow);
425      }
426      None => tracing::info!("no system bus, so there is no Bluetooth"),
427    }
428    Bluetooth {
429      service: Some(Rc::clone(self)),
430      adapters: adapters.read_only(),
431      devices: devices.read_only(),
432    }
433  }
434
435  /// Shows the adapters and devices as they are now.
436  fn publish(&self) {
437    // Set apart from the borrow, since effects may ask for more.
438    let shown = self.state.borrow().as_ref().map(|state| {
439      let objects = || {
440        state
441          .objects
442          .iter()
443          .map(|(path, object)| (path.clone(), object))
444      };
445      let adapters = objects()
446        .filter_map(|(path, object)| {
447          Some(BluetoothAdapter::from_props(path, object.adapter.as_ref()?))
448        })
449        .take(MAX_ADAPTERS)
450        .collect::<Vec<_>>();
451      let mut devices = objects()
452        .filter_map(|(path, object)| {
453          let device = object.device.as_ref()?;
454          let call = state.calls.get(&path).map(|(call, _)| *call);
455          Some(BluetoothDevice::from_props(
456            path,
457            device,
458            object.battery.as_ref(),
459            call,
460          ))
461        })
462        .collect::<Vec<_>>();
463      // Past the cap, connected and paired ones first, so the devices a
464      // person uses never hide behind strangers discovery found.
465      if devices.len() > MAX_DEVICES {
466        devices.sort_by_key(|device| (!device.connected, !device.paired));
467        devices.truncate(MAX_DEVICES);
468        devices.sort_by(|a, b| a.path.cmp(&b.path));
469      }
470      (state.adapters, adapters, state.devices, devices)
471    });
472    if let Some((adapters_signal, adapters, devices_signal, devices)) = shown {
473      set_changed(adapters_signal, adapters);
474      set_changed(devices_signal, devices);
475    }
476  }
477
478  /// Shows `device` in `state` while a call is out for it. The call's
479  /// number.
480  fn mark(&self, device: &str, state: BluetoothDeviceState) -> u64 {
481    let number = {
482      let mut held = self.state.borrow_mut();
483      let Some(held) = held.as_mut() else {
484        return 0;
485      };
486      held.next_call += 1;
487      held
488        .calls
489        .insert(device.to_owned(), (state, held.next_call));
490      held.next_call
491    };
492    self.publish();
493    number
494  }
495
496  /// Ends call `number` for `device`, which BlueZ answered, unless another
497  /// call or a change of `Connected` ended it already.
498  fn unmark(&self, device: &str, number: u64) {
499    let ended = {
500      let mut state = self.state.borrow_mut();
501      let Some(calls) = state.as_mut().map(|state| &mut state.calls) else {
502        return;
503      };
504      let current = calls.get(device).is_some_and(|(_, at)| *at == number);
505      current && calls.remove(device).is_some()
506    };
507    if ended {
508      self.publish();
509    }
510  }
511}
512
513impl Daemon for BluetoothService {
514  const NAME: &'static str = BLUEZ;
515
516  /// Only what adapters and devices are: BlueZ also broadcasts media,
517  /// GATT values and other programs' notifications, which would each
518  /// wake the shell for nothing.
519  fn rules() -> Vec<String> {
520    let mut rules = vec![format!(
521      "type='signal',sender='{BLUEZ}',interface='{OBJECT_MANAGER}'"
522    )];
523    rules.extend([ADAPTER, DEVICE, BATTERY].map(|interface| {
524      format!(
525        "type='signal',sender='{BLUEZ}',interface='{PROPERTIES}',member='PropertiesChanged',\
526         arg0='{interface}'"
527      )
528    }));
529    rules
530  }
531
532  fn alive(&self) -> bool {
533    let state = self.state.borrow();
534    state
535      .as_ref()
536      .is_some_and(|state| state.adapters.try_with(|_| ()).is_some())
537  }
538
539  fn clear(&self) -> u64 {
540    let generation = {
541      let mut state = self.state.borrow_mut();
542      let Some(state) = state.as_mut() else {
543        return 0;
544      };
545      state.objects.clear();
546      state.calls.clear();
547      state.connection = None;
548      state.generation += 1;
549      state.generation
550    };
551    self.publish();
552    generation
553  }
554
555  async fn read(self: Rc<Self>, connection: Connection, generation: u64) {
556    let call = connection.call_method(
557      Some(BLUEZ),
558      "/",
559      Some(OBJECT_MANAGER),
560      "GetManagedObjects",
561      &(),
562    );
563    let read = async {
564      let reply = self.shared.timeout(CALL_TIMEOUT, call).await?;
565      reply
566        .body()
567        .deserialize::<HashMap<OwnedObjectPath, HashMap<String, Props>>>()
568    };
569    let managed = match read.await {
570      Ok(managed) => managed,
571      Err(error) => return tracing::warn!(%error, "couldn't read BlueZ"),
572    };
573    // In path order, so the caps keep the same objects every time.
574    let managed = managed
575      .into_iter()
576      .map(|(path, interfaces)| (path.as_str().to_owned(), interfaces))
577      .collect::<BTreeMap<_, _>>();
578    {
579      let mut state = self.state.borrow_mut();
580      let Some(state) = state
581        .as_mut()
582        .filter(|state| state.generation == generation)
583      else {
584        return;
585      };
586      state.objects.clear();
587      for (path, interfaces) in managed {
588        add(&mut state.objects, path, interfaces);
589      }
590      state.connection = Some(connection);
591    }
592    self.publish();
593  }
594
595  fn signal(self: &Rc<Self>, _: &Connection, message: &Message) {
596    if let Some((path, interface, changed, dropped)) = dbus::properties_changed(message) {
597      {
598        let mut state = self.state.borrow_mut();
599        let Some(state) = state.as_mut() else {
600          return;
601        };
602        // Reaching where a call was headed ends it, as that can come before
603        // the answer. A change the other way waits for the answer.
604        let reached = match dbus::get::<bool>(&changed, "Connected") {
605          Some(true) => Some(BluetoothDeviceState::Connecting),
606          Some(false) => Some(BluetoothDeviceState::Disconnecting),
607          None => None,
608        };
609        let headed = state.calls.get(&path).map(|(call, _)| *call);
610        if interface == DEVICE && reached.is_some() && headed == reached {
611          state.calls.remove(&path);
612        }
613        let props = state
614          .objects
615          .get_mut(&path)
616          .and_then(|object| object.slot(&interface)?.as_mut());
617        let Some(props) = props else {
618          return;
619        };
620        let shown = changed
621          .keys()
622          .chain(&dropped)
623          .any(|name| read(&interface, name));
624        dbus::apply(props, changed, dropped);
625        if !shown {
626          return;
627        }
628      }
629      return self.publish();
630    }
631    let header = message.header();
632    if header.interface().map(|interface| interface.as_str()) != Some(OBJECT_MANAGER) {
633      return;
634    }
635    let body = message.body();
636    {
637      let mut state = self.state.borrow_mut();
638      let Some(state) = state.as_mut() else {
639        return;
640      };
641      match header.member().map(|member| member.as_str()) {
642        Some("InterfacesAdded") => {
643          let Ok((path, interfaces)) =
644            body.deserialize::<(OwnedObjectPath, HashMap<String, Props>)>()
645          else {
646            return;
647          };
648          add(&mut state.objects, path.as_str().to_owned(), interfaces);
649        }
650        Some("InterfacesRemoved") => {
651          let Ok((path, interfaces)) = body.deserialize::<(OwnedObjectPath, Vec<String>)>() else {
652            return;
653          };
654          let Some(object) = state.objects.get_mut(path.as_str()) else {
655            return;
656          };
657          for interface in interfaces {
658            if let Some(slot) = object.slot(&interface) {
659              *slot = None;
660            }
661          }
662          if object.adapter.is_none() && object.device.is_none() {
663            state.objects.remove(path.as_str());
664          }
665        }
666        _ => return,
667      }
668    }
669    self.publish();
670  }
671}