Release 1.0
This commit is contained in:
parent
009ea6da0e
commit
730a8b61d7
32 changed files with 6629 additions and 0 deletions
66
breadd/src/adapters/hyprland.rs
Normal file
66
breadd/src/adapters/hyprland.rs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use bread_shared::{now_unix_ms, AdapterSource, RawEvent};
|
||||
use serde_json::json;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::net::UnixStream;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::adapters::Adapter;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct HyprlandAdapter;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Adapter for HyprlandAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
"hyprland"
|
||||
}
|
||||
|
||||
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
|
||||
debug!("hyprland adapter started");
|
||||
let socket = hyprland_event_socket()?;
|
||||
let stream = UnixStream::connect(&socket).await?;
|
||||
let reader = BufReader::new(stream);
|
||||
let mut lines = reader.lines();
|
||||
|
||||
while let Some(line) = lines.next_line().await? {
|
||||
let (kind, data) = parse_hyprland_line(&line);
|
||||
tx.send(RawEvent {
|
||||
source: AdapterSource::Hyprland,
|
||||
kind: "hyprland.event".to_string(),
|
||||
payload: json!({
|
||||
"kind": kind,
|
||||
"raw": line,
|
||||
"data": data,
|
||||
}),
|
||||
timestamp: now_unix_ms(),
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
|
||||
warn!("hyprland socket closed");
|
||||
Err(anyhow!("hyprland socket closed"))
|
||||
}
|
||||
}
|
||||
|
||||
fn hyprland_event_socket() -> Result<PathBuf> {
|
||||
let instance = env::var("HYPRLAND_INSTANCE_SIGNATURE")
|
||||
.map_err(|_| anyhow!("HYPRLAND_INSTANCE_SIGNATURE is not set"))?;
|
||||
let runtime = env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string());
|
||||
Ok(PathBuf::from(runtime)
|
||||
.join("hypr")
|
||||
.join(instance)
|
||||
.join(".socket2.sock"))
|
||||
}
|
||||
|
||||
fn parse_hyprland_line(line: &str) -> (String, String) {
|
||||
if let Some((kind, data)) = line.split_once(">>") {
|
||||
return (kind.to_string(), data.to_string());
|
||||
}
|
||||
|
||||
("unknown".to_string(), line.to_string())
|
||||
}
|
||||
109
breadd/src/adapters/mod.rs
Normal file
109
breadd/src/adapters/mod.rs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use bread_shared::RawEvent;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tracing::info;
|
||||
|
||||
use crate::core::config::Config;
|
||||
use crate::core::supervisor::spawn_supervised;
|
||||
|
||||
pub mod hyprland;
|
||||
pub mod network;
|
||||
pub mod power;
|
||||
pub mod udev;
|
||||
pub mod network_rtnetlink;
|
||||
pub mod power_upower;
|
||||
|
||||
#[async_trait]
|
||||
pub trait Adapter: Send + Sync {
|
||||
fn name(&self) -> &'static str;
|
||||
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()>;
|
||||
async fn on_connect(&self) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn on_disconnect(&self) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Manager {
|
||||
raw_tx: mpsc::Sender<RawEvent>,
|
||||
config: Config,
|
||||
shutdown_rx: watch::Receiver<bool>,
|
||||
}
|
||||
|
||||
impl Manager {
|
||||
pub fn new(
|
||||
raw_tx: mpsc::Sender<RawEvent>,
|
||||
config: Config,
|
||||
shutdown_rx: watch::Receiver<bool>,
|
||||
) -> Self {
|
||||
Self {
|
||||
raw_tx,
|
||||
config,
|
||||
shutdown_rx,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start_all(&self) -> Result<()> {
|
||||
info!("starting adapters");
|
||||
|
||||
if self.config.adapters.udev.enabled {
|
||||
let adapter = udev::UdevAdapter::new(self.config.adapters.udev.subsystems.clone());
|
||||
adapter.enumerate_existing(&self.raw_tx).await?;
|
||||
self.spawn_adapter(adapter);
|
||||
}
|
||||
|
||||
if self.config.adapters.hyprland.enabled {
|
||||
self.spawn_adapter(hyprland::HyprlandAdapter::default());
|
||||
}
|
||||
|
||||
if self.config.adapters.power.enabled {
|
||||
// Prefer UPower DBus adapter; fall back to sysfs poller
|
||||
let upower = power_upower::UPowerAdapter::new();
|
||||
if let Ok(adapter) = upower {
|
||||
self.spawn_adapter(adapter);
|
||||
} else {
|
||||
self.spawn_adapter(power::PowerAdapter::new(
|
||||
self.config.adapters.power.poll_interval_secs,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if self.config.adapters.network.enabled {
|
||||
// Prefer rtnetlink-based adapter; fall back to existing sysfs-based adapter
|
||||
let rt = network_rtnetlink::RtnetlinkAdapter::new();
|
||||
if let Ok(adapter) = rt {
|
||||
self.spawn_adapter(adapter);
|
||||
} else {
|
||||
self.spawn_adapter(network::NetworkAdapter::default());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn spawn_adapter<A>(&self, adapter: A)
|
||||
where
|
||||
A: Adapter + Clone + 'static,
|
||||
{
|
||||
let name = adapter.name();
|
||||
let tx = self.raw_tx.clone();
|
||||
let shutdown_rx = self.shutdown_rx.clone();
|
||||
let shutdown_for_task = shutdown_rx.clone();
|
||||
spawn_supervised(name, shutdown_rx, move || {
|
||||
let adapter = adapter.clone();
|
||||
let tx = tx.clone();
|
||||
let mut shutdown_rx = shutdown_for_task.clone();
|
||||
async move {
|
||||
adapter.on_connect().await?;
|
||||
let result = tokio::select! {
|
||||
result = adapter.run(tx) => result,
|
||||
_ = shutdown_rx.changed() => Ok(()),
|
||||
};
|
||||
adapter.on_disconnect().await?;
|
||||
result
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
93
breadd/src/adapters/network.rs
Normal file
93
breadd/src/adapters/network.rs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
|
||||
use anyhow::Result;
|
||||
use bread_shared::{now_unix_ms, AdapterSource, RawEvent};
|
||||
use serde_json::json;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::{sleep, Duration};
|
||||
use tracing::debug;
|
||||
|
||||
use crate::adapters::Adapter;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct NetworkAdapter;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Adapter for NetworkAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
"network"
|
||||
}
|
||||
|
||||
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
|
||||
debug!("network adapter started");
|
||||
let mut last = read_network_state();
|
||||
tx.send(network_raw_event(&last)).await?;
|
||||
|
||||
loop {
|
||||
sleep(Duration::from_secs(5)).await;
|
||||
let now = read_network_state();
|
||||
if now != last {
|
||||
tx.send(network_raw_event(&now)).await?;
|
||||
last = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
struct NetworkSnapshot {
|
||||
interfaces: BTreeMap<String, bool>,
|
||||
online: bool,
|
||||
}
|
||||
|
||||
fn network_raw_event(snapshot: &NetworkSnapshot) -> RawEvent {
|
||||
let interfaces = snapshot
|
||||
.interfaces
|
||||
.iter()
|
||||
.map(|(name, up)| (name.clone(), json!({ "up": up })))
|
||||
.collect::<serde_json::Map<String, serde_json::Value>>();
|
||||
|
||||
RawEvent {
|
||||
source: AdapterSource::Network,
|
||||
kind: "network.snapshot".to_string(),
|
||||
payload: json!({
|
||||
"online": snapshot.online,
|
||||
"interfaces": interfaces,
|
||||
}),
|
||||
timestamp: now_unix_ms(),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_network_state() -> NetworkSnapshot {
|
||||
let mut interfaces = BTreeMap::new();
|
||||
|
||||
if let Ok(entries) = fs::read_dir("/sys/class/net") {
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name == "lo" {
|
||||
continue;
|
||||
}
|
||||
let oper = fs::read_to_string(entry.path().join("operstate")).unwrap_or_default();
|
||||
let up = oper.trim() == "up";
|
||||
interfaces.insert(name, up);
|
||||
}
|
||||
}
|
||||
|
||||
let online = has_default_route();
|
||||
|
||||
NetworkSnapshot { interfaces, online }
|
||||
}
|
||||
|
||||
fn has_default_route() -> bool {
|
||||
if let Ok(routes) = fs::read_to_string("/proc/net/route") {
|
||||
for line in routes.lines().skip(1) {
|
||||
let cols: Vec<&str> = line.split_whitespace().collect();
|
||||
if cols.len() > 2 && cols[1] == "00000000" {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
151
breadd/src/adapters/network_rtnetlink.rs
Normal file
151
breadd/src/adapters/network_rtnetlink.rs
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
use bread_shared::{AdapterSource, RawEvent};
|
||||
use futures_util::StreamExt;
|
||||
use netlink_packet_route::RtnlMessage;
|
||||
use rtnetlink::new_connection;
|
||||
use serde_json::json;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, info};
|
||||
|
||||
use super::Adapter;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RtnetlinkAdapter;
|
||||
|
||||
impl RtnetlinkAdapter {
|
||||
pub fn new() -> Result<Self> {
|
||||
// Try to create a connection to validate presence of rtnetlink
|
||||
let conn = new_connection();
|
||||
match conn {
|
||||
Ok((connection, _handle, _messages)) => {
|
||||
// Spawn and immediately drop the connection task; we just validated
|
||||
tokio::spawn(connection);
|
||||
Ok(Self)
|
||||
}
|
||||
Err(e) => Err(anyhow!(e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Adapter for RtnetlinkAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
"rtnetlink-network"
|
||||
}
|
||||
|
||||
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
|
||||
info!("rtnetlink adapter starting");
|
||||
let (connection, _handle, mut messages) = new_connection()?;
|
||||
tokio::spawn(connection);
|
||||
|
||||
while let Some((message, _addr)) = messages.next().await {
|
||||
match message.payload {
|
||||
netlink_packet_core::NetlinkPayload::InnerMessage(RtnlMessage::NewLink(link)) => {
|
||||
let ifname = link.nlas.iter().find_map(|nla| match nla {
|
||||
netlink_packet_route::link::nlas::Nla::IfName(name) => Some(name.clone()),
|
||||
_ => None,
|
||||
});
|
||||
let mtu = link.nlas.iter().find_map(|nla| match nla {
|
||||
netlink_packet_route::link::nlas::Nla::Mtu(mtu) => Some(*mtu),
|
||||
_ => None,
|
||||
});
|
||||
let netns_id = link.nlas.iter().find_map(|nla| match nla {
|
||||
netlink_packet_route::link::nlas::Nla::NetnsId(id) => Some(*id),
|
||||
_ => None,
|
||||
});
|
||||
let netns_fd = link.nlas.iter().find_map(|nla| match nla {
|
||||
netlink_packet_route::link::nlas::Nla::NetNsFd(fd) => Some(*fd),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
let up = link.header.flags & (libc::IFF_UP as u32) != 0;
|
||||
if let Some(name) = ifname {
|
||||
let kind = if up { "link.up" } else { "link.down" };
|
||||
let payload = json!({
|
||||
"ifname": name,
|
||||
"index": link.header.index,
|
||||
"mtu": mtu,
|
||||
"netns_id": netns_id,
|
||||
"netns_fd": netns_fd
|
||||
});
|
||||
let _ = tx.send(RawEvent { source: AdapterSource::Network, kind: kind.to_string(), payload, timestamp: bread_shared::now_unix_ms() }).await;
|
||||
}
|
||||
}
|
||||
netlink_packet_core::NetlinkPayload::InnerMessage(RtnlMessage::NewRoute(route)) => {
|
||||
// Heuristic: if destination is default (empty), treat as default-route change
|
||||
let is_default = route.header.destination_prefix_length == 0;
|
||||
if is_default {
|
||||
let gateway = route.nlas.iter().find_map(|nla| match nla {
|
||||
netlink_packet_route::route::nlas::Nla::Gateway(gw) => Some(gw.clone()),
|
||||
_ => None,
|
||||
});
|
||||
let gateway_ip = gateway.as_deref().and_then(ip_from_bytes);
|
||||
let payload = json!({
|
||||
"gateway": gateway_ip,
|
||||
"table": route.header.table
|
||||
});
|
||||
let _ = tx.send(RawEvent { source: AdapterSource::Network, kind: "route.default.changed".to_string(), payload, timestamp: bread_shared::now_unix_ms() }).await;
|
||||
}
|
||||
}
|
||||
netlink_packet_core::NetlinkPayload::InnerMessage(RtnlMessage::NewAddress(addr)) => {
|
||||
let address = addr.nlas.iter().find_map(|nla| match nla {
|
||||
netlink_packet_route::address::nlas::Nla::Address(bytes) => Some(bytes.clone()),
|
||||
netlink_packet_route::address::nlas::Nla::Local(bytes) => Some(bytes.clone()),
|
||||
_ => None,
|
||||
});
|
||||
let label = addr.nlas.iter().find_map(|nla| match nla {
|
||||
netlink_packet_route::address::nlas::Nla::Label(label) => Some(label.clone()),
|
||||
_ => None,
|
||||
});
|
||||
let ip = address.as_deref().and_then(ip_from_bytes);
|
||||
let payload = json!({
|
||||
"ifindex": addr.header.index,
|
||||
"prefix_len": addr.header.prefix_len,
|
||||
"family": addr.header.family,
|
||||
"address": ip,
|
||||
"label": label
|
||||
});
|
||||
let _ = tx.send(RawEvent { source: AdapterSource::Network, kind: "address.added".to_string(), payload, timestamp: bread_shared::now_unix_ms() }).await;
|
||||
}
|
||||
netlink_packet_core::NetlinkPayload::InnerMessage(RtnlMessage::DelAddress(addr)) => {
|
||||
let address = addr.nlas.iter().find_map(|nla| match nla {
|
||||
netlink_packet_route::address::nlas::Nla::Address(bytes) => Some(bytes.clone()),
|
||||
netlink_packet_route::address::nlas::Nla::Local(bytes) => Some(bytes.clone()),
|
||||
_ => None,
|
||||
});
|
||||
let label = addr.nlas.iter().find_map(|nla| match nla {
|
||||
netlink_packet_route::address::nlas::Nla::Label(label) => Some(label.clone()),
|
||||
_ => None,
|
||||
});
|
||||
let ip = address.as_deref().and_then(ip_from_bytes);
|
||||
let payload = json!({
|
||||
"ifindex": addr.header.index,
|
||||
"prefix_len": addr.header.prefix_len,
|
||||
"family": addr.header.family,
|
||||
"address": ip,
|
||||
"label": label
|
||||
});
|
||||
let _ = tx.send(RawEvent { source: AdapterSource::Network, kind: "address.removed".to_string(), payload, timestamp: bread_shared::now_unix_ms() }).await;
|
||||
}
|
||||
_ => {
|
||||
debug!("unhandled netlink message");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn ip_from_bytes(bytes: &[u8]) -> Option<String> {
|
||||
match bytes.len() {
|
||||
4 => Some(IpAddr::V4(Ipv4Addr::new(bytes[0], bytes[1], bytes[2], bytes[3])).to_string()),
|
||||
16 => {
|
||||
let octets: [u8; 16] = bytes.try_into().ok()?;
|
||||
Some(IpAddr::V6(Ipv6Addr::from(octets)).to_string())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
92
breadd/src/adapters/power.rs
Normal file
92
breadd/src/adapters/power.rs
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
use bread_shared::{now_unix_ms, AdapterSource, RawEvent};
|
||||
use serde_json::json;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::{sleep, Duration};
|
||||
use tracing::debug;
|
||||
|
||||
use crate::adapters::Adapter;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PowerAdapter {
|
||||
poll_interval_secs: u64,
|
||||
}
|
||||
|
||||
impl PowerAdapter {
|
||||
pub fn new(poll_interval_secs: u64) -> Self {
|
||||
Self { poll_interval_secs }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Adapter for PowerAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
"power"
|
||||
}
|
||||
|
||||
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
|
||||
debug!("power adapter started");
|
||||
|
||||
let mut last = read_power_state();
|
||||
tx.send(power_raw_event(&last)).await?;
|
||||
|
||||
loop {
|
||||
sleep(Duration::from_secs(self.poll_interval_secs.max(5))).await;
|
||||
let now = read_power_state();
|
||||
if now != last {
|
||||
tx.send(power_raw_event(&now)).await?;
|
||||
last = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
struct PowerSnapshot {
|
||||
ac_connected: bool,
|
||||
battery_percent: Option<u8>,
|
||||
}
|
||||
|
||||
fn power_raw_event(snapshot: &PowerSnapshot) -> RawEvent {
|
||||
RawEvent {
|
||||
source: AdapterSource::Power,
|
||||
kind: "power.snapshot".to_string(),
|
||||
payload: json!({
|
||||
"ac_connected": snapshot.ac_connected,
|
||||
"battery_percent": snapshot.battery_percent,
|
||||
}),
|
||||
timestamp: now_unix_ms(),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_power_state() -> PowerSnapshot {
|
||||
let power_dir = Path::new("/sys/class/power_supply");
|
||||
let mut ac_connected = false;
|
||||
let mut battery_percent = None;
|
||||
|
||||
if let Ok(entries) = fs::read_dir(power_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let typ = fs::read_to_string(path.join("type")).unwrap_or_default();
|
||||
if typ.trim().eq_ignore_ascii_case("Mains") || typ.trim().eq_ignore_ascii_case("USB") {
|
||||
let online = fs::read_to_string(path.join("online")).unwrap_or_default();
|
||||
if online.trim() == "1" {
|
||||
ac_connected = true;
|
||||
}
|
||||
} else if typ.trim().eq_ignore_ascii_case("Battery") {
|
||||
let cap = fs::read_to_string(path.join("capacity")).unwrap_or_default();
|
||||
if let Ok(parsed) = cap.trim().parse::<u8>() {
|
||||
battery_percent = Some(parsed.min(100));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PowerSnapshot {
|
||||
ac_connected,
|
||||
battery_percent,
|
||||
}
|
||||
}
|
||||
147
breadd/src/adapters/power_upower.rs
Normal file
147
breadd/src/adapters/power_upower.rs
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
use bread_shared::{AdapterSource, RawEvent};
|
||||
use futures_util::StreamExt;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, info};
|
||||
use zbus::{Message, MessageStream};
|
||||
use zbus::zvariant::{OwnedObjectPath, OwnedValue};
|
||||
|
||||
use super::Adapter;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct UPowerAdapter;
|
||||
|
||||
impl UPowerAdapter {
|
||||
pub fn new() -> Result<Self> {
|
||||
// Attempt to connect to system bus to validate availability
|
||||
// We don't actually open the connection here because zbus::Connection::system() is async.
|
||||
Ok(Self)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Adapter for UPowerAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
"upower"
|
||||
}
|
||||
|
||||
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
|
||||
info!("UPower adapter starting (attempting DBus subscription)");
|
||||
|
||||
// Defer loading zbus until runtime to avoid build-time optional complexity
|
||||
match zbus::Connection::system().await {
|
||||
Ok(conn) => {
|
||||
let payload = json!({"message": "upower:connected"});
|
||||
let _ = tx
|
||||
.send(RawEvent {
|
||||
source: AdapterSource::Power,
|
||||
kind: "power.upower.connected".to_string(),
|
||||
payload,
|
||||
timestamp: bread_shared::now_unix_ms(),
|
||||
})
|
||||
.await;
|
||||
|
||||
let mut stream = MessageStream::from(&conn);
|
||||
while let Some(result) = stream.next().await {
|
||||
match result {
|
||||
Ok(message) => match parse_upower_message(&message) {
|
||||
Ok(event) => {
|
||||
let _ = tx.send(event).await;
|
||||
}
|
||||
Err(err) => {
|
||||
debug!("upower parse error: {err:?}");
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
debug!("upower stream error: {err:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
// If DBus connection fails, fall back to periodic polling handled elsewhere
|
||||
Err(anyhow!(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_upower_message(message: &Message) -> Result<RawEvent> {
|
||||
let header = message.header()?;
|
||||
let interface = header.interface()?.map(|v| v.as_str()).unwrap_or("");
|
||||
let member = header.member()?.map(|v| v.as_str()).unwrap_or("");
|
||||
let path = header.path()?.map(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
if interface == "org.freedesktop.UPower" {
|
||||
match member {
|
||||
"DeviceAdded" => {
|
||||
let (device_path,): (OwnedObjectPath,) = message.body()?;
|
||||
let payload = json!({"device_path": device_path.as_str()});
|
||||
return Ok(RawEvent {
|
||||
source: AdapterSource::Power,
|
||||
kind: "power.device.added".to_string(),
|
||||
payload,
|
||||
timestamp: bread_shared::now_unix_ms(),
|
||||
});
|
||||
}
|
||||
"DeviceRemoved" => {
|
||||
let (device_path,): (OwnedObjectPath,) = message.body()?;
|
||||
let payload = json!({"device_path": device_path.as_str()});
|
||||
return Ok(RawEvent {
|
||||
source: AdapterSource::Power,
|
||||
kind: "power.device.removed".to_string(),
|
||||
payload,
|
||||
timestamp: bread_shared::now_unix_ms(),
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if interface == "org.freedesktop.DBus.Properties" && member == "PropertiesChanged" {
|
||||
let (iface, changed, invalidated): (String, HashMap<String, OwnedValue>, Vec<String>) =
|
||||
message.body()?;
|
||||
if iface == "org.freedesktop.UPower.Device" {
|
||||
let changed_json = serde_json::to_value(&changed).unwrap_or_else(|_| json!({}));
|
||||
let normalized = json!({
|
||||
"percentage": changed_json.get("Percentage").and_then(|v| v.as_f64()),
|
||||
"state": changed_json.get("State").and_then(|v| v.as_u64()),
|
||||
"time_to_empty": changed_json.get("TimeToEmpty").and_then(|v| v.as_i64()),
|
||||
"time_to_full": changed_json.get("TimeToFull").and_then(|v| v.as_i64()),
|
||||
"is_present": changed_json.get("IsPresent").and_then(|v| v.as_bool()),
|
||||
"battery_type": changed_json.get("Type").and_then(|v| v.as_u64()),
|
||||
"online": changed_json.get("Online").and_then(|v| v.as_bool()),
|
||||
"native_path": changed_json.get("NativePath").and_then(|v| v.as_str()),
|
||||
"model": changed_json.get("Model").and_then(|v| v.as_str()),
|
||||
"vendor": changed_json.get("Vendor").and_then(|v| v.as_str()),
|
||||
"serial": changed_json.get("Serial").and_then(|v| v.as_str()),
|
||||
"update_time": changed_json.get("UpdateTime").and_then(|v| v.as_u64()),
|
||||
});
|
||||
let payload = json!({
|
||||
"path": path,
|
||||
"properties": changed_json,
|
||||
"invalidated": invalidated,
|
||||
"normalized": normalized
|
||||
});
|
||||
|
||||
return Ok(RawEvent {
|
||||
source: AdapterSource::Power,
|
||||
kind: "power.device.changed".to_string(),
|
||||
payload,
|
||||
timestamp: bread_shared::now_unix_ms(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(RawEvent {
|
||||
source: AdapterSource::Power,
|
||||
kind: "power.upower.signal".to_string(),
|
||||
payload: json!({"interface": interface, "member": member, "path": path}),
|
||||
timestamp: bread_shared::now_unix_ms(),
|
||||
})
|
||||
}
|
||||
265
breadd/src/adapters/udev.rs
Normal file
265
breadd/src/adapters/udev.rs
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
use bread_shared::{now_unix_ms, AdapterSource, RawEvent};
|
||||
use serde_json::json;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::{sleep, Duration};
|
||||
use tracing::debug;
|
||||
|
||||
use crate::adapters::Adapter;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct UdevAdapter {
|
||||
subsystems: Vec<String>,
|
||||
}
|
||||
|
||||
impl UdevAdapter {
|
||||
pub fn new(subsystems: Vec<String>) -> Self {
|
||||
Self { subsystems }
|
||||
}
|
||||
|
||||
pub async fn enumerate_existing(&self, tx: &mpsc::Sender<RawEvent>) -> Result<()> {
|
||||
let devices = enumerate_with_udev(&self.subsystems).unwrap_or_else(|_| {
|
||||
scan_devices(&self.subsystems).unwrap_or_default()
|
||||
});
|
||||
|
||||
for device in devices {
|
||||
tx.send(RawEvent {
|
||||
source: AdapterSource::Udev,
|
||||
kind: "udev.enumerate".to_string(),
|
||||
payload: json!({
|
||||
"action": "add",
|
||||
"id": device.id,
|
||||
"name": device.name,
|
||||
"subsystem": device.subsystem,
|
||||
}),
|
||||
timestamp: now_unix_ms(),
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Adapter for UdevAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
"udev"
|
||||
}
|
||||
|
||||
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
|
||||
debug!("udev adapter started");
|
||||
if let Ok(()) = run_udev_monitor(self.subsystems.clone(), tx.clone()).await {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Fallback for environments where monitor sockets are unavailable.
|
||||
let mut known: HashMap<String, ScannedDevice> = scan_devices(&self.subsystems)?
|
||||
.into_iter()
|
||||
.map(|d| (d.id.clone(), d))
|
||||
.collect();
|
||||
|
||||
loop {
|
||||
let current = scan_devices(&self.subsystems)?;
|
||||
let current_map: HashMap<String, ScannedDevice> = current
|
||||
.into_iter()
|
||||
.map(|d| (d.id.clone(), d))
|
||||
.collect();
|
||||
|
||||
for (id, dev) in ¤t_map {
|
||||
if !known.contains_key(id) {
|
||||
tx.send(raw_change_event("add", dev)).await?;
|
||||
}
|
||||
}
|
||||
|
||||
for (id, dev) in &known {
|
||||
if !current_map.contains_key(id) {
|
||||
tx.send(raw_change_event("remove", dev)).await?;
|
||||
}
|
||||
}
|
||||
|
||||
known = current_map;
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct ScannedDevice {
|
||||
id: String,
|
||||
name: String,
|
||||
subsystem: String,
|
||||
}
|
||||
|
||||
async fn run_udev_monitor(subsystems: Vec<String>, tx: mpsc::Sender<RawEvent>) -> Result<()> {
|
||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
let mut builder = udev::MonitorBuilder::new()?;
|
||||
for subsystem in &subsystems {
|
||||
builder = builder.match_subsystem(subsystem)?;
|
||||
}
|
||||
let monitor = builder.listen()?;
|
||||
|
||||
for event in monitor.iter() {
|
||||
let action = event
|
||||
.action()
|
||||
.map(|a| a.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "change".to_string());
|
||||
let subsystem = event
|
||||
.subsystem()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let name = event
|
||||
.property_value("ID_MODEL")
|
||||
.or_else(|| event.property_value("NAME"))
|
||||
.map(|v| v.to_string_lossy().to_string())
|
||||
.or_else(|| event.devnode().map(|n| n.display().to_string()))
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let id = event
|
||||
.syspath()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
let msg = RawEvent {
|
||||
source: AdapterSource::Udev,
|
||||
kind: "udev.change".to_string(),
|
||||
payload: json!({
|
||||
"action": action,
|
||||
"id": id,
|
||||
"name": name,
|
||||
"subsystem": subsystem,
|
||||
}),
|
||||
timestamp: now_unix_ms(),
|
||||
};
|
||||
|
||||
if tx.blocking_send(msg).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await??;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn enumerate_with_udev(subsystems: &[String]) -> Result<Vec<ScannedDevice>> {
|
||||
let mut enumerator = udev::Enumerator::new()?;
|
||||
for subsystem in subsystems {
|
||||
enumerator.match_subsystem(subsystem)?;
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
for dev in enumerator.scan_devices()? {
|
||||
let subsystem = dev
|
||||
.subsystem()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let name = dev
|
||||
.property_value("ID_MODEL")
|
||||
.or_else(|| dev.property_value("NAME"))
|
||||
.map(|v| v.to_string_lossy().to_string())
|
||||
.or_else(|| dev.sysname().to_str().map(ToString::to_string))
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let id = dev.syspath().to_string_lossy().to_string();
|
||||
|
||||
out.push(ScannedDevice {
|
||||
id,
|
||||
name,
|
||||
subsystem,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn raw_change_event(action: &str, dev: &ScannedDevice) -> RawEvent {
|
||||
RawEvent {
|
||||
source: AdapterSource::Udev,
|
||||
kind: "udev.change".to_string(),
|
||||
payload: json!({
|
||||
"action": action,
|
||||
"id": dev.id,
|
||||
"name": dev.name,
|
||||
"subsystem": dev.subsystem,
|
||||
}),
|
||||
timestamp: now_unix_ms(),
|
||||
}
|
||||
}
|
||||
|
||||
fn scan_devices(subsystems: &[String]) -> Result<Vec<ScannedDevice>> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
if subsystems.iter().any(|s| s == "drm") {
|
||||
let drm_dir = Path::new("/sys/class/drm");
|
||||
if drm_dir.exists() {
|
||||
for entry in fs::read_dir(drm_dir)? {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if !name.contains('-') {
|
||||
continue;
|
||||
}
|
||||
let status = fs::read_to_string(entry.path().join("status")).unwrap_or_default();
|
||||
if status.trim() == "connected" {
|
||||
out.push(ScannedDevice {
|
||||
id: format!("drm:{name}"),
|
||||
name,
|
||||
subsystem: "drm".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if subsystems.iter().any(|s| s == "input") {
|
||||
let input_dir = Path::new("/dev/input/by-id");
|
||||
if input_dir.exists() {
|
||||
for entry in fs::read_dir(input_dir)? {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
out.push(ScannedDevice {
|
||||
id: format!("input:{name}"),
|
||||
name,
|
||||
subsystem: "input".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if subsystems.iter().any(|s| s == "power_supply") {
|
||||
let pwr_dir = Path::new("/sys/class/power_supply");
|
||||
if pwr_dir.exists() {
|
||||
for entry in fs::read_dir(pwr_dir)? {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
out.push(ScannedDevice {
|
||||
id: format!("power_supply:{name}"),
|
||||
name,
|
||||
subsystem: "power_supply".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if subsystems.iter().any(|s| s == "usb") {
|
||||
let usb_dir = Path::new("/sys/bus/usb/devices");
|
||||
if usb_dir.exists() {
|
||||
for entry in fs::read_dir(usb_dir)? {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if !name.contains(':') && name.chars().any(|c| c.is_ascii_digit()) {
|
||||
out.push(ScannedDevice {
|
||||
id: format!("usb:{name}"),
|
||||
name,
|
||||
subsystem: "usb".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue