Commiting for bread sync

This commit is contained in:
Breadway 2026-05-16 19:44:19 +08:00
parent 9a471f3158
commit fc27916a5d
13 changed files with 2040 additions and 79 deletions

View 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("/"), "");
}
}

View file

@ -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();

View file

@ -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");

View file

@ -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]

View file

@ -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)
}