Commiting for bread sync
This commit is contained in:
parent
425b746780
commit
4072f64fcb
13 changed files with 2040 additions and 79 deletions
255
breadd/src/adapters/bluetooth.rs
Normal file
255
breadd/src/adapters/bluetooth.rs
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
use bread_shared::{now_unix_ms, AdapterSource, RawEvent};
|
||||
use futures_util::StreamExt;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, info};
|
||||
use zbus::zvariant::{OwnedObjectPath, OwnedValue};
|
||||
use zbus::{Message, MessageStream};
|
||||
|
||||
use super::Adapter;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BluetoothAdapter;
|
||||
|
||||
impl BluetoothAdapter {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Emit `bluetooth.enumerate` events for every device that is currently connected.
|
||||
/// Errors are swallowed — Bluetooth hardware being absent is not a daemon startup failure.
|
||||
pub async fn enumerate_existing(&self, tx: &mpsc::Sender<RawEvent>) {
|
||||
match try_enumerate(tx).await {
|
||||
Ok(n) => debug!("bluetooth enumerated {n} connected device(s)"),
|
||||
Err(e) => debug!("bluetooth enumeration skipped: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Adapter for BluetoothAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
"bluetooth"
|
||||
}
|
||||
|
||||
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
|
||||
info!("bluetooth adapter starting");
|
||||
|
||||
let conn = zbus::Connection::system()
|
||||
.await
|
||||
.map_err(|e| anyhow!("bluetooth D-Bus unavailable: {e}"))?;
|
||||
|
||||
let mut stream = MessageStream::from(&conn);
|
||||
while let Some(result) = stream.next().await {
|
||||
match result {
|
||||
Ok(message) => {
|
||||
if let Some(event) = parse_bluetooth_message(&message) {
|
||||
if tx.send(event).await.is_err() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => debug!("bluetooth stream error: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn try_enumerate(tx: &mpsc::Sender<RawEvent>) -> Result<usize> {
|
||||
let conn = zbus::Connection::system().await?;
|
||||
let msg = conn
|
||||
.call_method(
|
||||
Some("org.bluez"),
|
||||
"/",
|
||||
Some("org.freedesktop.DBus.ObjectManager"),
|
||||
"GetManagedObjects",
|
||||
&(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let objects: HashMap<OwnedObjectPath, HashMap<String, HashMap<String, OwnedValue>>> =
|
||||
msg.body()?;
|
||||
|
||||
let mut count = 0;
|
||||
for (path, interfaces) in objects {
|
||||
let Some(props) = interfaces.get("org.bluez.Device1") else {
|
||||
continue;
|
||||
};
|
||||
let props_json = serde_json::to_value(props).unwrap_or_else(|_| json!({}));
|
||||
if !props_json
|
||||
.get("Connected")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let name = props_json
|
||||
.get("Name")
|
||||
.or_else(|| props_json.get("Alias"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let address = props_json
|
||||
.get("Address")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
let _ = tx
|
||||
.send(RawEvent {
|
||||
source: AdapterSource::Bluetooth,
|
||||
kind: "bluetooth.enumerate".to_string(),
|
||||
payload: json!({
|
||||
"path": path.as_str(),
|
||||
"address": address,
|
||||
"name": name,
|
||||
"properties": props_json,
|
||||
}),
|
||||
timestamp: now_unix_ms(),
|
||||
})
|
||||
.await;
|
||||
count += 1;
|
||||
}
|
||||
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
fn parse_bluetooth_message(message: &Message) -> Option<RawEvent> {
|
||||
let header = message.header().ok()?;
|
||||
let interface = header.interface().ok()??.as_str().to_string();
|
||||
let member = header.member().ok()??.as_str().to_string();
|
||||
let path = header
|
||||
.path()
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|p| p.as_str().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Connected / disconnected — PropertiesChanged on a BlueZ device object
|
||||
if interface == "org.freedesktop.DBus.Properties" && member == "PropertiesChanged" {
|
||||
if !path.starts_with("/org/bluez/") {
|
||||
return None;
|
||||
}
|
||||
let (iface, changed, _): (String, HashMap<String, OwnedValue>, Vec<String>) =
|
||||
message.body().ok()?;
|
||||
if iface != "org.bluez.Device1" {
|
||||
return None;
|
||||
}
|
||||
let changed_json = serde_json::to_value(&changed).ok()?;
|
||||
let connected = changed_json.get("Connected").and_then(|v| v.as_bool())?;
|
||||
let address = address_from_path(&path);
|
||||
let kind = if connected {
|
||||
"bluetooth.device.connected"
|
||||
} else {
|
||||
"bluetooth.device.disconnected"
|
||||
};
|
||||
return Some(RawEvent {
|
||||
source: AdapterSource::Bluetooth,
|
||||
kind: kind.to_string(),
|
||||
payload: json!({
|
||||
"path": path,
|
||||
"address": address,
|
||||
"properties": changed_json,
|
||||
}),
|
||||
timestamp: now_unix_ms(),
|
||||
});
|
||||
}
|
||||
|
||||
// Device paired / discovered — InterfacesAdded from BlueZ ObjectManager
|
||||
if interface == "org.freedesktop.DBus.ObjectManager" && member == "InterfacesAdded" {
|
||||
let (obj_path, interfaces): (
|
||||
OwnedObjectPath,
|
||||
HashMap<String, HashMap<String, OwnedValue>>,
|
||||
) = message.body().ok()?;
|
||||
let obj_str = obj_path.as_str();
|
||||
if !obj_str.starts_with("/org/bluez/") {
|
||||
return None;
|
||||
}
|
||||
let props = interfaces.get("org.bluez.Device1")?;
|
||||
let props_json = serde_json::to_value(props).ok()?;
|
||||
let name = props_json
|
||||
.get("Name")
|
||||
.or_else(|| props_json.get("Alias"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let address = props_json
|
||||
.get("Address")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| address_from_path(obj_str));
|
||||
return Some(RawEvent {
|
||||
source: AdapterSource::Bluetooth,
|
||||
kind: "bluetooth.device.added".to_string(),
|
||||
payload: json!({
|
||||
"path": obj_str,
|
||||
"address": address,
|
||||
"name": name,
|
||||
"properties": props_json,
|
||||
}),
|
||||
timestamp: now_unix_ms(),
|
||||
});
|
||||
}
|
||||
|
||||
// Device unpaired — InterfacesRemoved from BlueZ ObjectManager
|
||||
if interface == "org.freedesktop.DBus.ObjectManager" && member == "InterfacesRemoved" {
|
||||
let (obj_path, interfaces): (OwnedObjectPath, Vec<String>) = message.body().ok()?;
|
||||
let obj_str = obj_path.as_str();
|
||||
if !obj_str.starts_with("/org/bluez/") {
|
||||
return None;
|
||||
}
|
||||
if !interfaces.iter().any(|i| i == "org.bluez.Device1") {
|
||||
return None;
|
||||
}
|
||||
let address = address_from_path(obj_str);
|
||||
return Some(RawEvent {
|
||||
source: AdapterSource::Bluetooth,
|
||||
kind: "bluetooth.device.removed".to_string(),
|
||||
payload: json!({
|
||||
"path": obj_str,
|
||||
"address": address,
|
||||
}),
|
||||
timestamp: now_unix_ms(),
|
||||
});
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// `/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF` → `"AA:BB:CC:DD:EE:FF"`
|
||||
fn address_from_path(path: &str) -> String {
|
||||
path.rsplit('/')
|
||||
.next()
|
||||
.and_then(|s| s.strip_prefix("dev_"))
|
||||
.map(|s| s.replace('_', ":"))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn address_from_path_parses_standard_bluez_path() {
|
||||
assert_eq!(
|
||||
address_from_path("/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF"),
|
||||
"AA:BB:CC:DD:EE:FF"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn address_from_path_returns_empty_for_adapter_path() {
|
||||
assert_eq!(address_from_path("/org/bluez/hci0"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn address_from_path_returns_empty_for_root() {
|
||||
assert_eq!(address_from_path("/"), "");
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ use tracing::info;
|
|||
use crate::core::config::Config;
|
||||
use crate::core::supervisor::spawn_supervised;
|
||||
|
||||
pub mod bluetooth;
|
||||
pub mod hyprland;
|
||||
pub mod network;
|
||||
pub mod network_rtnetlink;
|
||||
|
|
@ -86,6 +87,12 @@ impl Manager {
|
|||
}
|
||||
}
|
||||
|
||||
if self.config.adapters.bluetooth.enabled {
|
||||
let adapter = bluetooth::BluetoothAdapter::new();
|
||||
adapter.enumerate_existing(&self.raw_tx).await;
|
||||
self.spawn_adapter(adapter);
|
||||
}
|
||||
|
||||
if self.config.adapters.network.enabled {
|
||||
// Prefer rtnetlink-based adapter; fall back to existing sysfs-based adapter
|
||||
let rt = network_rtnetlink::RtnetlinkAdapter::new();
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@ pub struct AdaptersConfig {
|
|||
pub power: PowerConfig,
|
||||
#[serde(default)]
|
||||
pub network: AdapterToggle,
|
||||
#[serde(default)]
|
||||
pub bluetooth: AdapterToggle,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
|
|
@ -306,6 +308,7 @@ mod tests {
|
|||
assert!(cfg.adapters.udev.enabled);
|
||||
assert!(cfg.adapters.power.enabled);
|
||||
assert!(cfg.adapters.network.enabled);
|
||||
assert!(cfg.adapters.bluetooth.enabled);
|
||||
assert_eq!(cfg.adapters.power.poll_interval_secs, 30);
|
||||
assert_eq!(cfg.events.dedup_window_ms, 100);
|
||||
assert_eq!(cfg.notifications.default_timeout_ms, 3000);
|
||||
|
|
@ -359,6 +362,9 @@ poll_interval_secs = 5
|
|||
[adapters.network]
|
||||
enabled = false
|
||||
|
||||
[adapters.bluetooth]
|
||||
enabled = false
|
||||
|
||||
[events]
|
||||
dedup_window_ms = 250
|
||||
|
||||
|
|
@ -380,6 +386,7 @@ notify_send_path = "/usr/local/bin/notify-send"
|
|||
assert!(!cfg.adapters.power.enabled);
|
||||
assert_eq!(cfg.adapters.power.poll_interval_secs, 5);
|
||||
assert!(!cfg.adapters.network.enabled);
|
||||
assert!(!cfg.adapters.bluetooth.enabled);
|
||||
assert_eq!(cfg.events.dedup_window_ms, 250);
|
||||
assert_eq!(cfg.notifications.default_timeout_ms, 1000);
|
||||
assert_eq!(cfg.notifications.default_urgency, "critical");
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ impl EventNormalizer {
|
|||
AdapterSource::Hyprland => self.normalize_hyprland(raw),
|
||||
AdapterSource::Power => self.normalize_power(raw),
|
||||
AdapterSource::Network => self.normalize_network(raw),
|
||||
AdapterSource::Bluetooth => self.normalize_bluetooth(raw),
|
||||
AdapterSource::System => vec![BreadEvent {
|
||||
event: raw.kind.clone(),
|
||||
timestamp: raw.timestamp,
|
||||
|
|
@ -303,6 +304,83 @@ impl EventNormalizer {
|
|||
events
|
||||
}
|
||||
|
||||
fn normalize_bluetooth(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
||||
let path = raw
|
||||
.payload
|
||||
.get("path")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown");
|
||||
let address = raw
|
||||
.payload
|
||||
.get("address")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown");
|
||||
let name = raw
|
||||
.payload
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| {
|
||||
raw.payload
|
||||
.pointer("/properties/Name")
|
||||
.or_else(|| raw.payload.pointer("/properties/Alias"))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
.unwrap_or("unknown");
|
||||
|
||||
match raw.kind.as_str() {
|
||||
"bluetooth.enumerate" | "bluetooth.device.connected" => vec![BreadEvent {
|
||||
event: "bread.device.connected".to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Bluetooth,
|
||||
data: json!({
|
||||
"id": path,
|
||||
"device": "unknown",
|
||||
"name": name,
|
||||
"address": address,
|
||||
"subsystem": "bluetooth",
|
||||
"raw": raw.payload,
|
||||
}),
|
||||
}],
|
||||
"bluetooth.device.disconnected" => vec![BreadEvent {
|
||||
event: "bread.device.disconnected".to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Bluetooth,
|
||||
data: json!({
|
||||
"id": path,
|
||||
"device": "unknown",
|
||||
"name": name,
|
||||
"address": address,
|
||||
"subsystem": "bluetooth",
|
||||
"raw": raw.payload,
|
||||
}),
|
||||
}],
|
||||
"bluetooth.device.added" => vec![BreadEvent {
|
||||
event: "bread.bluetooth.device.paired".to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Bluetooth,
|
||||
data: json!({
|
||||
"id": path,
|
||||
"name": name,
|
||||
"address": address,
|
||||
"subsystem": "bluetooth",
|
||||
"raw": raw.payload,
|
||||
}),
|
||||
}],
|
||||
"bluetooth.device.removed" => vec![BreadEvent {
|
||||
event: "bread.bluetooth.device.unpaired".to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Bluetooth,
|
||||
data: json!({
|
||||
"id": path,
|
||||
"address": address,
|
||||
"subsystem": "bluetooth",
|
||||
"raw": raw.payload,
|
||||
}),
|
||||
}],
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_network(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
||||
let online = raw
|
||||
.payload
|
||||
|
|
@ -661,6 +739,123 @@ mod tests {
|
|||
assert!(names.contains(&"bread.power.battery.critical"));
|
||||
}
|
||||
|
||||
// ─── Bluetooth ─────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn bluetooth_connected_emits_device_connected() {
|
||||
let n = EventNormalizer::new(0);
|
||||
let ev = raw(
|
||||
AdapterSource::Bluetooth,
|
||||
"bluetooth",
|
||||
json!({
|
||||
"path": "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
|
||||
"address": "AA:BB:CC:DD:EE:FF",
|
||||
"properties": { "Connected": true },
|
||||
}),
|
||||
1,
|
||||
);
|
||||
let out = n.normalize(&raw(
|
||||
AdapterSource::Bluetooth,
|
||||
"bluetooth.device.connected",
|
||||
ev.payload.clone(),
|
||||
1,
|
||||
));
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].event, "bread.device.connected");
|
||||
assert_eq!(out[0].data.get("address").unwrap(), "AA:BB:CC:DD:EE:FF");
|
||||
assert_eq!(out[0].data.get("subsystem").unwrap(), "bluetooth");
|
||||
assert_eq!(out[0].data.get("device").unwrap(), "unknown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bluetooth_disconnected_emits_device_disconnected() {
|
||||
let n = EventNormalizer::new(0);
|
||||
let out = n.normalize(&raw(
|
||||
AdapterSource::Bluetooth,
|
||||
"bluetooth.device.disconnected",
|
||||
json!({
|
||||
"path": "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
|
||||
"address": "AA:BB:CC:DD:EE:FF",
|
||||
"properties": { "Connected": false },
|
||||
}),
|
||||
1,
|
||||
));
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].event, "bread.device.disconnected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bluetooth_enumerate_includes_name() {
|
||||
let n = EventNormalizer::new(0);
|
||||
let out = n.normalize(&raw(
|
||||
AdapterSource::Bluetooth,
|
||||
"bluetooth.enumerate",
|
||||
json!({
|
||||
"path": "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
|
||||
"address": "AA:BB:CC:DD:EE:FF",
|
||||
"name": "WH-1000XM4",
|
||||
"properties": {},
|
||||
}),
|
||||
1,
|
||||
));
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].event, "bread.device.connected");
|
||||
assert_eq!(out[0].data.get("name").unwrap(), "WH-1000XM4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bluetooth_paired_emits_bluetooth_specific_event() {
|
||||
let n = EventNormalizer::new(0);
|
||||
let out = n.normalize(&raw(
|
||||
AdapterSource::Bluetooth,
|
||||
"bluetooth.device.added",
|
||||
json!({
|
||||
"path": "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
|
||||
"address": "AA:BB:CC:DD:EE:FF",
|
||||
"name": "My Headphones",
|
||||
"properties": {},
|
||||
}),
|
||||
1,
|
||||
));
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].event, "bread.bluetooth.device.paired");
|
||||
assert_eq!(out[0].data.get("name").unwrap(), "My Headphones");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bluetooth_unpaired_emits_bluetooth_specific_event() {
|
||||
let n = EventNormalizer::new(0);
|
||||
let out = n.normalize(&raw(
|
||||
AdapterSource::Bluetooth,
|
||||
"bluetooth.device.removed",
|
||||
json!({
|
||||
"path": "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
|
||||
"address": "AA:BB:CC:DD:EE:FF",
|
||||
}),
|
||||
1,
|
||||
));
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].event, "bread.bluetooth.device.unpaired");
|
||||
assert_eq!(out[0].data.get("address").unwrap(), "AA:BB:CC:DD:EE:FF");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bluetooth_name_falls_back_to_properties() {
|
||||
let n = EventNormalizer::new(0);
|
||||
let out = n.normalize(&raw(
|
||||
AdapterSource::Bluetooth,
|
||||
"bluetooth.device.connected",
|
||||
json!({
|
||||
"path": "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
|
||||
"address": "AA:BB:CC:DD:EE:FF",
|
||||
"properties": { "Connected": true, "Name": "Fallback Name" },
|
||||
}),
|
||||
1,
|
||||
));
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].data.get("name").unwrap(), "Fallback Name");
|
||||
}
|
||||
|
||||
// ─── Network ───────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -934,6 +934,74 @@ impl LuaEngine {
|
|||
|
||||
bread.set("fs", fs_tbl)?;
|
||||
|
||||
// bread.bluetooth — BlueZ control
|
||||
let bluetooth_tbl = self.lua.create_table()?;
|
||||
|
||||
let power_fn = self.lua.create_function(move |_lua, enabled: bool| {
|
||||
bluetooth_spawn(move || async move {
|
||||
if let Err(e) = bluetooth_set_powered(enabled).await {
|
||||
tracing::warn!("bread.bluetooth.power failed: {e}");
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
})?;
|
||||
bluetooth_tbl.set("power", power_fn)?;
|
||||
|
||||
let powered_fn = self.lua.create_function(move |_lua, ()| {
|
||||
Ok(bluetooth_query(|| bluetooth_get_powered()).ok())
|
||||
})?;
|
||||
bluetooth_tbl.set("powered", powered_fn)?;
|
||||
|
||||
let connect_fn = self.lua.create_function(move |_lua, address: String| {
|
||||
bluetooth_spawn(move || async move {
|
||||
if let Err(e) = bluetooth_connect(address).await {
|
||||
tracing::warn!("bread.bluetooth.connect failed: {e}");
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
})?;
|
||||
bluetooth_tbl.set("connect", connect_fn)?;
|
||||
|
||||
let disconnect_fn = self.lua.create_function(move |_lua, address: String| {
|
||||
bluetooth_spawn(move || async move {
|
||||
if let Err(e) = bluetooth_disconnect(address).await {
|
||||
tracing::warn!("bread.bluetooth.disconnect failed: {e}");
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
})?;
|
||||
bluetooth_tbl.set("disconnect", disconnect_fn)?;
|
||||
|
||||
let scan_fn = self.lua.create_function(move |_lua, enabled: bool| {
|
||||
bluetooth_spawn(move || async move {
|
||||
if let Err(e) = bluetooth_set_scanning(enabled).await {
|
||||
tracing::warn!("bread.bluetooth.scan failed: {e}");
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
})?;
|
||||
bluetooth_tbl.set("scan", scan_fn)?;
|
||||
|
||||
let devices_fn = self.lua.create_function(move |lua, ()| {
|
||||
let devs = match bluetooth_query(|| bluetooth_list_devices()) {
|
||||
Ok(d) => d,
|
||||
Err(_) => return Ok(Value::Nil),
|
||||
};
|
||||
let tbl = lua.create_table()?;
|
||||
for (i, dev) in devs.iter().enumerate() {
|
||||
let dt = lua.create_table()?;
|
||||
dt.set("address", dev.address.clone())?;
|
||||
dt.set("name", dev.name.clone())?;
|
||||
dt.set("connected", dev.connected)?;
|
||||
dt.set("paired", dev.paired)?;
|
||||
tbl.set(i + 1, dt)?;
|
||||
}
|
||||
Ok(Value::Table(tbl))
|
||||
})?;
|
||||
bluetooth_tbl.set("devices", devices_fn)?;
|
||||
|
||||
bread.set("bluetooth", bluetooth_tbl)?;
|
||||
|
||||
globals.set("bread", bread)?;
|
||||
self.install_require_loader()?;
|
||||
self.install_wait_helper()?;
|
||||
|
|
@ -2193,3 +2261,199 @@ fn list_lua_files(root: &Path) -> Result<Vec<PathBuf>> {
|
|||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ─── Bluetooth helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/// Spawn a dedicated thread with its own Tokio runtime for a fire-and-forget
|
||||
/// async Bluetooth operation. Needed because the Lua thread runs inside
|
||||
/// `block_on` on a current-thread runtime, so nested `block_on` is forbidden.
|
||||
fn bluetooth_spawn<F, Fut>(factory: F)
|
||||
where
|
||||
F: FnOnce() -> Fut + Send + 'static,
|
||||
Fut: std::future::Future<Output = ()>,
|
||||
{
|
||||
std::thread::spawn(move || {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("bluetooth action thread")
|
||||
.block_on(factory());
|
||||
});
|
||||
}
|
||||
|
||||
/// Like `bluetooth_spawn` but waits for the result via a sync channel so Lua
|
||||
/// gets a return value.
|
||||
fn bluetooth_query<F, Fut, T>(factory: F) -> anyhow::Result<T>
|
||||
where
|
||||
F: FnOnce() -> Fut + Send + 'static,
|
||||
Fut: std::future::Future<Output = anyhow::Result<T>>,
|
||||
T: Send + 'static,
|
||||
{
|
||||
let (tx, rx) = std::sync::mpsc::sync_channel(1);
|
||||
std::thread::spawn(move || {
|
||||
let result = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("bluetooth query thread")
|
||||
.block_on(factory());
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
rx.recv().map_err(|_| anyhow::anyhow!("bluetooth query thread failed"))?
|
||||
}
|
||||
|
||||
async fn bluetooth_find_adapter(conn: &zbus::Connection) -> anyhow::Result<String> {
|
||||
use zbus::zvariant::{OwnedObjectPath, OwnedValue};
|
||||
let msg = conn
|
||||
.call_method(
|
||||
Some("org.bluez"),
|
||||
"/",
|
||||
Some("org.freedesktop.DBus.ObjectManager"),
|
||||
"GetManagedObjects",
|
||||
&(),
|
||||
)
|
||||
.await?;
|
||||
let objects: std::collections::HashMap<
|
||||
OwnedObjectPath,
|
||||
std::collections::HashMap<String, std::collections::HashMap<String, OwnedValue>>,
|
||||
> = msg.body()?;
|
||||
for (path, interfaces) in &objects {
|
||||
if interfaces.contains_key("org.bluez.Adapter1") {
|
||||
return Ok(path.as_str().to_string());
|
||||
}
|
||||
}
|
||||
Err(anyhow::anyhow!("no Bluetooth adapter found"))
|
||||
}
|
||||
|
||||
async fn bluetooth_set_powered(enabled: bool) -> anyhow::Result<()> {
|
||||
let conn = zbus::Connection::system().await?;
|
||||
let adapter = bluetooth_find_adapter(&conn).await?;
|
||||
conn.call_method(
|
||||
Some("org.bluez"),
|
||||
adapter.as_str(),
|
||||
Some("org.freedesktop.DBus.Properties"),
|
||||
"Set",
|
||||
&(
|
||||
"org.bluez.Adapter1",
|
||||
"Powered",
|
||||
zbus::zvariant::Value::from(enabled),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn bluetooth_get_powered() -> anyhow::Result<bool> {
|
||||
let conn = zbus::Connection::system().await?;
|
||||
let adapter = bluetooth_find_adapter(&conn).await?;
|
||||
let msg = conn
|
||||
.call_method(
|
||||
Some("org.bluez"),
|
||||
adapter.as_str(),
|
||||
Some("org.freedesktop.DBus.Properties"),
|
||||
"Get",
|
||||
&("org.bluez.Adapter1", "Powered"),
|
||||
)
|
||||
.await?;
|
||||
let (value,): (zbus::zvariant::OwnedValue,) = msg.body()?;
|
||||
let json = serde_json::to_value(&value).unwrap_or(serde_json::json!(false));
|
||||
Ok(json.as_bool().unwrap_or(false))
|
||||
}
|
||||
|
||||
async fn bluetooth_connect(address: String) -> anyhow::Result<()> {
|
||||
let conn = zbus::Connection::system().await?;
|
||||
let adapter = bluetooth_find_adapter(&conn).await?;
|
||||
let dev_path = format!("{}/dev_{}", adapter, address.replace(':', "_"));
|
||||
conn.call_method(
|
||||
Some("org.bluez"),
|
||||
dev_path.as_str(),
|
||||
Some("org.bluez.Device1"),
|
||||
"Connect",
|
||||
&(),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn bluetooth_disconnect(address: String) -> anyhow::Result<()> {
|
||||
let conn = zbus::Connection::system().await?;
|
||||
let adapter = bluetooth_find_adapter(&conn).await?;
|
||||
let dev_path = format!("{}/dev_{}", adapter, address.replace(':', "_"));
|
||||
conn.call_method(
|
||||
Some("org.bluez"),
|
||||
dev_path.as_str(),
|
||||
Some("org.bluez.Device1"),
|
||||
"Disconnect",
|
||||
&(),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn bluetooth_set_scanning(enabled: bool) -> anyhow::Result<()> {
|
||||
let conn = zbus::Connection::system().await?;
|
||||
let adapter = bluetooth_find_adapter(&conn).await?;
|
||||
let method = if enabled { "StartDiscovery" } else { "StopDiscovery" };
|
||||
conn.call_method(
|
||||
Some("org.bluez"),
|
||||
adapter.as_str(),
|
||||
Some("org.bluez.Adapter1"),
|
||||
method,
|
||||
&(),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct BluetoothDevice {
|
||||
address: String,
|
||||
name: String,
|
||||
connected: bool,
|
||||
paired: bool,
|
||||
}
|
||||
|
||||
async fn bluetooth_list_devices() -> anyhow::Result<Vec<BluetoothDevice>> {
|
||||
use zbus::zvariant::{OwnedObjectPath, OwnedValue};
|
||||
let conn = zbus::Connection::system().await?;
|
||||
let msg = conn
|
||||
.call_method(
|
||||
Some("org.bluez"),
|
||||
"/",
|
||||
Some("org.freedesktop.DBus.ObjectManager"),
|
||||
"GetManagedObjects",
|
||||
&(),
|
||||
)
|
||||
.await?;
|
||||
let objects: std::collections::HashMap<
|
||||
OwnedObjectPath,
|
||||
std::collections::HashMap<String, std::collections::HashMap<String, OwnedValue>>,
|
||||
> = msg.body()?;
|
||||
|
||||
let mut devices = Vec::new();
|
||||
for (_, interfaces) in &objects {
|
||||
if let Some(props) = interfaces.get("org.bluez.Device1") {
|
||||
let json = serde_json::to_value(props).unwrap_or_else(|_| serde_json::json!({}));
|
||||
devices.push(BluetoothDevice {
|
||||
address: json
|
||||
.get("Address")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string(),
|
||||
name: json
|
||||
.get("Name")
|
||||
.or_else(|| json.get("Alias"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string(),
|
||||
connected: json
|
||||
.get("Connected")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false),
|
||||
paired: json
|
||||
.get("Paired")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(devices)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue