bluetooth-manager

tauri-plugin-bluetooth-manager#

Administra los adaptadores y los dispositivos Bluetooth del equipo a través de BlueZ, por D-Bus: encender el adaptador, escanear, emparejar, conectar y desconectar. Una sola consulta al arrancar y, después, sólo señales: lo que la interfaz muestra se pone al día sola, aunque el cambio lo haya hecho otra aplicación o el propio BlueZ.

  • Crate: tauri-plugin-bluetooth-manager
  • Paquete JS: @vasakgroup/plugin-bluetooth-manager
  • Plataforma: Linux con BlueZ (bus del sistema). Sin BlueZ el plugin carga igual, bluetooth_plugin_status devuelve false y los comandos fallan.

Instalación#

1
2
3
# src-tauri/Cargo.toml
[dependencies]
tauri-plugin-bluetooth-manager = { git = "https://github.com/Vasak-OS/tauri-plugin-bluetooth-manager", branch = "main" }
1
bun add @vasakgroup/plugin-bluetooth-manager
1
2
3
4
5
6
7
// src-tauri/src/lib.rs
fn main() {
    tauri::Builder::default()
        .plugin(tauri_plugin_bluetooth_manager::init())
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

Permisos#

Los identificadores llevan guion medio, y cada comando tiene el suyo más su deny- correspondiente. Se listan en el archivo de capacidades:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// src-tauri/capabilities/default.json
{
  "permissions": [
    "bluetooth-manager:allow-list-adapters",
    "bluetooth-manager:allow-set-adapter-powered",
    "bluetooth-manager:allow-get-adapter-state",
    "bluetooth-manager:allow-start-scan",
    "bluetooth-manager:allow-stop-scan",
    "bluetooth-manager:allow-list-devices",
    "bluetooth-manager:allow-list-paired-devices",
    "bluetooth-manager:allow-get-device-info",
    "bluetooth-manager:allow-connect-device",
    "bluetooth-manager:allow-disconnect-device",
    "bluetooth-manager:allow-bluetooth-plugin-status"
  ]
}

El plugin no publica un conjunto default utilizable —su lista interna nombra los permisos con guion bajo y los identificadores reales llevan guion medio—, así que la lista va completa, como arriba. Sólo hacen falta los permisos de los comandos que la aplicación use: bluetooth-manager:allow-list-adapters, por ejemplo, deja listar adaptadores y nada más.

API de JavaScript#

Cada función envuelve un comando del plugin. Las de comando rechazan con el mensaje de BlueZ como texto (D-Bus error: …); las ayudas de más abajo atrapan ese error y devuelven un valor por omisión, según dice cada una.

listAdapters(): Promise<AdapterInfo[]>#

Todos los adaptadores del equipo. La lista viene vacía si no hay ninguno.

setAdapterPowered(adapterPath: string, powered: boolean): Promise<void>#

Enciende (true) o apaga (false) el adaptador. Apagarlo corta las conexiones que tenga.

getAdapterState(adapterPath: string): Promise<AdapterInfo>#

El adaptador leído en el momento, sin esperar señales. Si la aplicación lo usa, necesita bluetooth-manager:allow-get-adapter-state.

startScan(adapterPath: string): Promise<void>#

Arranca el descubrimiento de dispositivos en el adaptador. Si ya había un escaneo en curso, no falla: lo da por bueno.

stopScan(adapterPath: string): Promise<void>#

Detiene el descubrimiento. Si no había ninguno, tampoco falla.

listDevices(adapterPath: string): Promise<DeviceInfo[]>#

Los dispositivos conocidos del adaptador —vistos, emparejados o conectados—, en el momento de la llamada.

getDeviceInfo(devicePath: string): Promise<DeviceInfo>#

Un dispositivo leído en el momento, por su ruta de objeto en el bus (/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF), incluida la batería si la publica.

listPairedDevices(adapterPath: string): Promise<DeviceInfo[]>#

Sólo los que están emparejados. Es un filtro de listDevices.

connectDevice(devicePath: string): Promise<void>#

Conecta el dispositivo. Si ya estaba conectado o la conexión estaba en curso, no falla.

disconnectDevice(devicePath: string): Promise<void>#

Desconecta el dispositivo. Si ya estaba desconectado, no falla.

isBluetoothPluginInitialized(): Promise<boolean>#

Si el plugin pudo hablar con BlueZ al arrancar. Es el mismo dato que bluetooth_plugin_status.

Además de las anteriores, el paquete trae ayudas que combinan esas llamadas:

isBluetoothAvailable(): Promise<boolean>#

Si hay al menos un adaptador. Ante un error devuelve false.

getDefaultAdapter(): Promise<AdapterInfo | null>#

El primer adaptador de la lista, o null si no hay ninguno. Ante un error devuelve null.

isBluetoothEnabled(): Promise<boolean>#

Si el adaptador por omisión está encendido. false si no hay adaptador o si la consulta falla.

toggleBluetooth(): Promise<boolean>#

Invierte el estado del adaptador por omisión y devuelve el estado nuevo. Falla si no hay adaptador: para encenderlo hace falta uno.

getConnectedDevicesCount(adapterPath: string): Promise<number>#

Cuántos dispositivos del adaptador están conectados. Ante un error devuelve 0.

getConnectedDevices(adapterPath: string): Promise<DeviceInfo[]>#

Los dispositivos conectados del adaptador. Ante un error devuelve una lista vacía.

getAvailableDevices(adapterPath: string): Promise<DeviceInfo[]>#

Los que no están conectados. Ante un error devuelve una lista vacía.

findDeviceByAddress(adapterPath: string, address: string): Promise<DeviceInfo | null>#

Busca por dirección MAC, sin distinguir mayúsculas. null si no está.

scanForDevices(adapterPath: string, timeoutMs = 10000): Promise<DeviceInfo[]>#

Escanea, espera timeoutMs (diez segundos por omisión), detiene el escaneo y devuelve la lista. Detiene el escaneo también si algo falla en el medio.

AdapterInfo#

Lo que describe un adaptador:

CampoTipoQué es
pathstringRuta del objeto en el bus, por ejemplo /org/bluez/hci0. Es lo que reciben los comandos.
addressstringDirección MAC del adaptador.
namestringNombre del adaptador, que fija el sistema (hci0).
aliasstringNombre visible, que puede cambiar quien usa el equipo.
classnumberClase de dispositivo, según Bluetooth SIG.
poweredbooleanSi el adaptador está encendido.
discoverablebooleanSi el equipo se deja ver por otros.
discoverableTimeoutnumberCuántos segundos dura el modo visible.
pairablebooleanSi acepta emparejamientos.
pairableTimeoutnumberCuántos segundos dura el modo emparejable.
discoveringbooleanSi hay un escaneo en curso.
uuidsstring[]Los servicios que el adaptador anuncia.
modalias?stringEl identificador del hardware, si BlueZ lo publica.

DeviceInfo#

Lo que describe un dispositivo. Los campos con ? pueden faltar, y faltar no es lo mismo que venir vacíos:

CampoTipoQué es
pathstringRuta del objeto en el bus. Es lo que reciben connectDevice y disconnectDevice.
addressstringDirección MAC.
name?stringNombre que anuncia el dispositivo.
alias?stringNombre con el que se lo conoce en el equipo; lo puede cambiar quien lo usa.
class?numberClase de dispositivo, según Bluetooth SIG.
appearance?numberAspecto declarado, un código de Bluetooth SIG.
icon?stringNombre de icono sugerido por BlueZ (audio-headset, input-mouse).
pairedbooleanSi está emparejado.
trustedbooleanSi está marcado como de confianza.
blockedbooleanSi está bloqueado.
legacyPairingbooleanSi usa el emparejamiento viejo (PIN).
rssi?numberPotencia de la señal recibida, en dBm.
txPower?numberPotencia de transmisión anunciada, en dBm.
connectedbooleanSi está conectado ahora.
uuidsstring[]Los servicios que anuncia.
adapterstringRuta del adaptador al que pertenece.
servicesResolvedbooleanSi BlueZ ya terminó de resolver sus servicios. Mientras sea false, la lista de uuids puede estar incompleta.
battery?numberBatería en porcentaje (0–100). Sólo lo que la publique; ausente no es cero, es «no lo sé».
manufacturer?stringFabricante, deducido de manufacturerData contra la tabla de identificadores de Bluetooth SIG.
manufacturerData?Record<string, number[]>Los datos crudos del fabricante: identificador → bytes.
serviceData?Record<string, number[]>Los datos crudos de servicios: UUID → bytes.
modalias?stringEl identificador del hardware, si lo publica.

Comandos Rust#

Los comandos que expone el plugin; el paquete de JavaScript envuelve cada uno. Menos bluetooth_plugin_status, todos leen o escriben BlueZ:

  • list_adapters() — Los adaptadores de la máquina.
  • set_adapter_powered(adapter_path, powered) — Enciende o apaga el adaptador.
  • get_adapter_state(adapter_path) — El adaptador leído en el momento.
  • start_scan(adapter_path) — Arranca el descubrimiento de dispositivos.
  • stop_scan(adapter_path) — Detiene el descubrimiento.
  • list_devices(adapter_path) — Los dispositivos conocidos del adaptador.
  • list_paired_devices(adapter_path) — Sólo los emparejados.
  • get_device_info(device_path) — Un dispositivo leído en el momento.
  • connect_device(device_path) — Conecta el dispositivo.
  • disconnect_device(device_path) — Desconecta el dispositivo.
  • bluetooth_plugin_status() — Si el plugin pudo inicializarse.

Eventos#

Todo lo que pasa en BlueZ llega al frontend por un solo evento, bluetooth-change, con un data distinto según el caso:

EventoCuándo se emiteCarga
bluetooth-changeCambió algo en los adaptadores o los dispositivos, o el plugin no pudo leer o decodificar una señalBluetoothChange

El payload trae changeType y, además, change_type con el mismo valor: el nombre viejo queda por compatibilidad con las aplicaciones que lo leían hasta la 2.1, está marcado como obsoleto a favor de changeType y se va en la próxima versión mayor. data lleva el objeto o el dato suelto:

changeTypeCuándodata
adapter-addedApareció un adaptadorAdapterInfo
adapter-removedSe fue un adaptador{ path }
adapter-property-changedCambió una propiedad del adaptador: encendido, visible, nombreAdapterInfo releído
device-addedApareció un dispositivoDeviceInfo
device-removedSe fue un dispositivo{ path }
device-connectedUn dispositivo se conectóDeviceInfo; { path, connected: true } si no se pudo releer
device-disconnectedUn dispositivo se desconectóDeviceInfo; { path } si no se pudo releer
device-property-changedCambió una propiedad del dispositivo: señal, batería, nombre, emparejamientoDeviceInfo releído
errorEl plugin no pudo leer o decodificar algo{ message }
dbus-errorSe cortó la conexión con el bus{ message }

El paquete exporta los nombres en BluetoothChangeType, para no escribirlos a mano:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import { listen } from "@tauri-apps/api/event";
import { BluetoothChangeType, listDevices } from "@vasakgroup/plugin-bluetooth-manager";

await listen("bluetooth-change", async (evento) => {
  const cambio = evento.payload;
  if (
    cambio.changeType === BluetoothChangeType.DEVICE_ADDED ||
    cambio.changeType === BluetoothChangeType.DEVICE_REMOVED
  ) {
    // volver a dibujar la lista de dispositivos
  }
});

Ejemplo#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from "vue";
import { listen } from "@tauri-apps/api/event";
import {
  listAdapters,
  listDevices,
  scanForDevices,
  toggleBluetooth,
  BluetoothChangeType,
  type AdapterInfo,
  type DeviceInfo,
} from "@vasakgroup/plugin-bluetooth-manager";

const adaptador = ref<AdapterInfo | null>(null);
const dispositivos = ref<DeviceInfo[]>([]);
const buscando = ref(false);
let dejarDeEscuchar: (() => void) | null = null;

async function recargar() {
  if (!adaptador.value) return;
  dispositivos.value = await listDevices(adaptador.value.path);
}

onMounted(async () => {
  adaptador.value = (await listAdapters())[0] ?? null;
  await recargar();

  dejarDeEscuchar = await listen("bluetooth-change", async (evento) => {
    const tipo = evento.payload.changeType;
    if (tipo === BluetoothChangeType.ADAPTER_PROPERTY_CHANGED) {
      adaptador.value = (await listAdapters())[0] ?? null;
    } else if (tipo.startsWith("device-")) {
      await recargar();
    }
  });
});

onUnmounted(() => dejarDeEscuchar?.());

async function buscar() {
  if (!adaptador.value) return;
  buscando.value = true;
  try {
    dispositivos.value = await scanForDevices(adaptador.value.path, 10000);
  } finally {
    buscando.value = false;
  }
}
</script>

<template>
  <p v-if="!adaptador">Este equipo no tiene Bluetooth.</p>

  <template v-else>
    <button @click="toggleBluetooth()">
      {{ adaptador.powered ? "Apagar" : "Encender" }}
    </button>
    <button :disabled="buscando" @click="buscar">
      {{ buscando ? "Buscando…" : "Buscar dispositivos" }}
    </button>

    <ul>
      <li v-for="dispositivo in dispositivos" :key="dispositivo.path">
        {{ dispositivo.alias ?? dispositivo.address }}
        <span v-if="dispositivo.connected"> · conectado</span>
      </li>
    </ul>
  </template>
</template>

Detalles de implementación#

  • El plugin abre una sola conexión con el bus del sistema y la guarda en su estado. start_scan y stop_scan la reutilizan en vez de abrir la propia, porque BlueZ ata el descubrimiento al cliente que lo pidió: con una conexión que se cierra al terminar la llamada, el escaneo arrancaba y moría al instante.
  • Antes de arrancar el escaneo fija SetDiscoveryFilter con Transport=auto, para cubrir tanto Bluetooth clásico como de bajo consumo. Si el adaptador no acepta el filtro, sigue con el que tenga por omisión en vez de abortar.
  • Los cambios de un dispositivo se avisan a lo sumo uno cada medio segundo, por dispositivo. No se descarta lo que llega dentro de la ventana: se pospone, así que el último valor nunca se pierde. Las claves Connected, Paired, Trusted, Blocked, Name y Alias se avisan en el acto, sin esperar.
  • La batería se pide aparte (org.bluez.Battery1). Los dispositivos que no la publican hacen que BlueZ conteste con un error, y el plugin lo traga: el campo queda ausente en vez de romper la lectura. Lo mismo vale para manufacturerData, serviceData y modalias, que se omiten cuando faltan.
  • La inicialización espera a BlueZ durante el setup del plugin. Si BlueZ no está, la aplicación abre igual: el error va al registro, bluetooth_plugin_status devuelve false y los comandos fallan con el mensaje del bus.
  • Deja un registro en ~/.logs/vasak/bluetooth.log, además de la salida estándar. Si el archivo no se puede abrir —un HOME de sólo lectura, por ejemplo—, el plugin no se cae: sigue registrando sólo por la salida estándar y lo avisa.
  • El registro propio no pisa el del anfitrión: instala sólo el suscriptor de tracing y deja libre el puente de log, que es de la aplicación.
  • Desde Rust, la aplicación llega al estado del plugin con el trait BluetoothManagerExt y app.bluetooth_manager().
  • Los errores llegan al frontend como texto, con el mensaje de BlueZ tal cual (D-Bus error: …).