tools

various tools I have been using throughout the years
Log | Files | Refs | README | LICENSE

commit 5d5c1bf870a89073a9689d39de3ce785f3534ae5
parent 048dfee3e3d2a8e5cb0f51b4aa3bc89494b54e96
Author: mtmn <miro@haravara.org>
Date:   Sat, 11 Apr 2026 21:48:20 +0200

feat!: remove plants


Former-commit-id: 7fdbf5c513acb2dc4aa31e2688ede56e3f6ef843
Former-commit-id: f5a4552d976795155ad678cb73b08bd9b03ce8e0
Former-commit-id: b70dd9948c9d2891ebdbbd103ca366ec23bc7e51
Diffstat:
MCargo.toml | 3---
MMODULE.bazel | 3---
Dplants-client/BUILD | 10----------
Dplants-client/Cargo.toml | 17-----------------
Dplants-client/src/main.rs | 131-------------------------------------------------------------------------------
Dplants-common/BUILD | 13-------------
Dplants-common/Cargo.toml | 13-------------
Dplants-common/src/lib.rs | 2--
Dplants-common/src/output.rs | 144-------------------------------------------------------------------------------
Dplants-common/src/status.rs | 109-------------------------------------------------------------------------------
Dplants-daemon/BUILD | 10----------
Dplants-daemon/Cargo.toml | 25-------------------------
Dplants-daemon/src/airpods.rs | 215-------------------------------------------------------------------------------
Dplants-daemon/src/airpods_consts.rs | 20--------------------
Dplants-daemon/src/bluetooth.rs | 155-------------------------------------------------------------------------------
Dplants-daemon/src/config.rs | 35-----------------------------------
Dplants-daemon/src/daemon_impl.rs | 10----------
Dplants-daemon/src/main.rs | 60------------------------------------------------------------
Dplants-daemon/src/packets/battery.rs | 132-------------------------------------------------------------------------------
Dplants-daemon/src/packets/in_ear.rs | 61-------------------------------------------------------------
Dplants-daemon/src/packets/metadata.rs | 49-------------------------------------------------
Dplants-daemon/src/packets/mod.rs | 5-----
Dplants-daemon/src/pbp.rs | 129-------------------------------------------------------------------------------
Dplants-daemon/src/pbp_client.rs | 188-------------------------------------------------------------------------------
24 files changed, 0 insertions(+), 1539 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml @@ -6,9 +6,6 @@ members = [ "lastfm-csv-export", "mack", "matchakey", - "plants-client", - "plants-common", - "plants-daemon", "scores", "virittaa", "battery-notify" diff --git a/MODULE.bazel b/MODULE.bazel @@ -51,9 +51,6 @@ crate.from_cargo( "//:diffamer/Cargo.toml", "//:hakunadata/Cargo.toml", "//:mack/Cargo.toml", - "//:plants-client/Cargo.toml", - "//:plants-common/Cargo.toml", - "//:plants-daemon/Cargo.toml", "//:scores/Cargo.toml", "//:virittaa/Cargo.toml", "//:lastfm-csv-export/Cargo.toml", diff --git a/plants-client/BUILD b/plants-client/BUILD @@ -1,10 +0,0 @@ -load("@crates//:defs.bzl", "all_crate_deps") -load("//bazel:rust.bzl", "rust_app") - -rust_app( - name = "plants-client", - aliases = { - "//plants-common:plants-common": "common", - }, - deps = all_crate_deps() + ["//plants-common"], -) diff --git a/plants-client/Cargo.toml b/plants-client/Cargo.toml @@ -1,17 +0,0 @@ -[package] -name = "plants-client" -version = "0.1.0" -edition = "2024" - -[[bin]] -name = "plants" -path = "src/main.rs" - -[dependencies] -plants-common = { path = "../plants-common", package = "plants-common" } -anyhow = "1.0.98" -futures = "0.3.31" -tokio = { version = "1.46.1", features = ["macros", "rt-multi-thread", "time", "process", "io-util", "sync", "fs", "net"] } -zbus = { version = "5.9.0", features = ["option-as-array"] } -serde_json = "1.0.141" -clap = { version = "4.5", features = ["derive"] } diff --git a/plants-client/src/main.rs b/plants-client/src/main.rs @@ -1,131 +0,0 @@ -use anyhow::Result; -use clap::Parser; -use common::output::Output; -use common::status::Status; -use futures::StreamExt; -use std::sync::Arc; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::net::TcpListener; -use tokio::sync::RwLock; -use zbus::{Connection, proxy}; - -#[derive(Parser, Debug)] -#[command(version, about, long_about = None)] -struct Args { - #[arg(long)] - host: Option<String>, -} - -#[proxy( - default_service = "org.mtmn.Plants", - default_path = "/org/mtmn/Plants", - interface = "org.mtmn.Plants" -)] -trait PlantsDaemon { - #[zbus(signal)] - async fn update(&self, status: Status); -} - -type SharedOutput = Arc<RwLock<String>>; - -#[tokio::main] -async fn main() -> Result<()> { - let args = Args::parse(); - let output_state = Arc::new(RwLock::new(String::new())); - - let initial = Output::not_connected(); - initial.print(); - - // Spawn DBus listener task - let output_clone = output_state.clone(); - let mut dbus_handle = tokio::spawn(async move { - if let Err(e) = listen_dbus(output_clone).await { - eprintln!("DBus listener error: {e}"); - } - }); - - // Create web server if host is provided - if let Some(host) = args.host { - let listener = TcpListener::bind(&host).await?; - - loop { - // Wait for either the dbus listener (which shouldn't exit) or a new connection - tokio::select! { - res = listener.accept() => { - match res { - Ok((socket, _)) => { - let state = output_state.clone(); - tokio::spawn(async move { - if let Err(e) = handle_connection(socket, state).await { - eprintln!("Error handling connection: {e}"); - } - }); - } - Err(e) => eprintln!("Accept error: {e}"), - } - } - _ = &mut dbus_handle => { - // DBus handle finished - break; - } - } - } - } else { - // If no host is defined, just await the dbus task - if let Err(e) = dbus_handle.await { - eprintln!("DBus task error: {e}"); - } - } - - Ok(()) -} - -async fn handle_connection(socket: tokio::net::TcpStream, state: SharedOutput) -> Result<()> { - let (reader, mut writer) = socket.into_split(); - let mut reader = BufReader::new(reader); - - let mut line = String::new(); - reader.read_line(&mut line).await?; - - let json = state.read().await.clone(); - let json = if json.is_empty() { - "{}".to_string() - } else { - json - }; - - let response = format!( - "HTTP/1.1 200 OK\r\n\ - Content-Type: application/json\r\n\ - Content-Length: {}\r\n\ - Access-Control-Allow-Origin: *\r\n\ - \r\n\ - {}", - json.len(), - json - ); - - writer.write_all(response.as_bytes()).await?; - writer.flush().await?; - - Ok(()) -} - -async fn listen_dbus(output_state: SharedOutput) -> Result<()> { - let connection = Connection::session().await?; - let proxy = PlantsDaemonProxy::new(&connection).await?; - let mut stream = proxy.receive_update().await?; - - while let Some(msg) = stream.next().await { - let status = msg.args()?.status; - - let merged_output = Output::from_status(&status); - merged_output.print(); - - let json = serde_json::to_string(&merged_output).unwrap_or_else(|_| "{}".to_string()); - let mut state = output_state.write().await; - *state = json; - } - - Ok(()) -} diff --git a/plants-common/BUILD b/plants-common/BUILD @@ -1,13 +0,0 @@ -load("@crates//:defs.bzl", "all_crate_deps") -load("@rules_rust//rust:defs.bzl", "rust_library") - -rust_library( - name = "plants-common", - srcs = glob(["src/**/*.rs"]), - edition = "2024", - rustc_flags = [ - "-Ctarget-cpu=native", - ], - visibility = ["//visibility:public"], - deps = all_crate_deps(), -) diff --git a/plants-common/Cargo.toml b/plants-common/Cargo.toml @@ -1,13 +0,0 @@ -[package] -name = "plants-common" -version = "0.1.0" -edition = "2024" - -[lib] -path = "src/lib.rs" - -[dependencies] -serde = { version = "1.0.219", features = ["derive"] } -serde_json = "1.0.141" -zbus = { version = "5.9.0", features = ["option-as-array"] } -zvariant = "5.9.2" diff --git a/plants-common/src/lib.rs b/plants-common/src/lib.rs @@ -1,2 +0,0 @@ -pub mod output; -pub mod status; diff --git a/plants-common/src/output.rs b/plants-common/src/output.rs @@ -1,144 +0,0 @@ -use std::fmt::Write as _; -use std::io::{Write, stdout}; - -use serde::Serialize; - -use crate::status::{BatteryStatus, Components, Status}; - -#[derive(Serialize, Debug)] -pub struct Output { - text: String, - tooltip: Option<String>, - class: Option<String>, - percentage: Option<f32>, -} - -impl Output { - #[must_use] - pub fn not_connected() -> Self { - Output { - text: "󰟦".into(), - tooltip: Some("Daemon not active".into()), - class: Some("disconnected".into()), - percentage: None, - } - } - - #[must_use] - pub fn from_status(status: &Status) -> Self { - if !status.is_valid() { - return Output::default(); - } - - let mut tooltip = String::new(); - if let Some(metadata) = &status.metadata { - let _ = writeln!(tooltip, "{} ({})", metadata.name, metadata.model); - } - - let Components { left, right, case } = &status.components; - for (idx, (name, component)) in [("Left", left), ("Right", right), ("Case", case)] - .iter() - .enumerate() - { - let Some(component) = component else { - continue; - }; - - let icon = match component.status { - BatteryStatus::Charging => "󰢝", - BatteryStatus::Discharging => match idx { - 0 => status.ear.left, - 1 => status.ear.right, - _ => crate::status::EarStatus::Disconnected, - } - .icon(), - BatteryStatus::Disconnected => continue, - }; - - let _ = writeln!(tooltip, "{icon} {name}: {}%", component.level); - } - - for device in &status.devices { - let icon = if let Some(text) = &device.text { - text.as_str() - } else { - match device.status { - BatteryStatus::Charging => "󰢝", - BatteryStatus::Discharging => "󰂯", - BatteryStatus::Disconnected => continue, - } - }; - let _ = writeln!(tooltip, "{icon} {}: {}%", device.name, device.battery); - } - - let mut min_level = status.min_pods(); - for device in &status.devices { - if device.status == BatteryStatus::Discharging { - min_level = min_level.min(device.battery); - } - } - - let is_low = min_level <= 15; - let class = ["connected", "connected-low"][usize::from(is_low)]; - - // Base text for pods - let mut text_parts = Vec::new(); - - let battery = ["", "󱃍"][usize::from(is_low)]; - if status.min_pods() != u8::MAX { - let min_pods = status.min_pods(); - text_parts.push(format!("󱡏{battery} {min_pods}%")); - } - - for device in &status.devices { - let icon = if let Some(text) = &device.text { - text.as_str() - } else { - match device.status { - BatteryStatus::Charging => "󰢝", - BatteryStatus::Discharging => "󰂯", - BatteryStatus::Disconnected => continue, - } - }; - text_parts.push(format!("{icon} {}%", device.battery)); - } - - let text = if text_parts.is_empty() { - // Default empty/disconnected state - format!("󱡏{battery}") - } else { - text_parts.join(" ") - }; - - Output { - text, - tooltip: Some(tooltip[..tooltip.len() - 1].to_owned()), - class: Some(class.into()), - percentage: if min_level == u8::MAX { - None - } else { - Some(f32::from(min_level) / 100.0) - }, - } - } - - #[allow(clippy::missing_panics_doc)] - pub fn print(&self) { - let str = serde_json::to_string(&self).unwrap(); - - let mut stdout = stdout(); - let _ = stdout.write_fmt(format_args!("{str}\n")); - let _ = stdout.flush(); - } -} - -impl Default for Output { - fn default() -> Self { - Output { - text: "󱡐".into(), - tooltip: None, - class: Some("disconnected".into()), - percentage: None, - } - } -} diff --git a/plants-common/src/status.rs b/plants-common/src/status.rs @@ -1,109 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::hash::{DefaultHasher, Hash, Hasher}; -use zbus::zvariant::Type; - -#[derive(Default, Hash, Clone, Debug, Serialize, Deserialize, Type)] -pub struct Status { - pub metadata: Option<Metadata>, - pub components: Components, - pub ear: InEar, - pub devices: Vec<GenericDeviceStatus>, -} - -#[derive(Hash, Debug, Clone, Serialize, Deserialize, Type)] -pub struct GenericDeviceStatus { - pub name: String, - pub battery: u8, - pub text: Option<String>, - pub status: BatteryStatus, -} - -#[derive(Hash, Clone, Debug, Serialize, Deserialize, Type)] -pub struct Metadata { - pub name: String, - pub model: String, -} - -#[derive(Default, Hash, Clone, Debug, Serialize, Deserialize, Type)] -pub struct Components { - pub left: Option<ComponentStatus>, - pub right: Option<ComponentStatus>, - pub case: Option<ComponentStatus>, -} - -#[derive(Default, Hash, Clone, Debug, Serialize, Deserialize, Type)] -pub struct InEar { - pub left: EarStatus, - pub right: EarStatus, -} - -#[derive(Hash, Clone, Debug, Serialize, Deserialize, Type)] -pub struct ComponentStatus { - pub level: u8, - pub status: BatteryStatus, -} - -#[derive(Hash, Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Type)] -pub enum BatteryStatus { - Charging, - Discharging, - Disconnected, -} - -#[derive(Default, Hash, Clone, Copy, Debug, Serialize, Deserialize, Type)] -pub enum EarStatus { - InEar, - NotInEar, - InCase, - #[default] - Disconnected, -} - -impl Status { - #[must_use] - pub fn hash(&self) -> u64 { - let mut hasher = DefaultHasher::new(); - Hash::hash(self, &mut hasher); - hasher.finish() - } - - #[must_use] - pub fn is_valid(&self) -> bool { - let Components { left, right, case } = &self.components; - left.is_some() || right.is_some() || case.is_some() || !self.devices.is_empty() - } - - #[must_use] - pub fn min_pods(&self) -> u8 { - let mut out = u8::MAX; - - let Components { left, right, .. } = &self.components; - for component in [&left, &right] { - if let Some(component) = &component - && matches!(component.status, BatteryStatus::Discharging) - { - out = out.min(component.level); - } - } - - out - } -} - -impl Components { - pub fn as_arr_mut(&mut self) -> [&mut Option<ComponentStatus>; 3] { - [&mut self.left, &mut self.right, &mut self.case] - } -} - -impl EarStatus { - #[must_use] - pub fn icon(&self) -> &'static str { - match self { - EarStatus::InEar => "󱡏", - EarStatus::NotInEar => "󱡒", - EarStatus::InCase => "󱡑", - EarStatus::Disconnected => "", - } - } -} diff --git a/plants-daemon/BUILD b/plants-daemon/BUILD @@ -1,10 +0,0 @@ -load("@crates//:defs.bzl", "all_crate_deps") -load("//bazel:rust.bzl", "rust_app") - -rust_app( - name = "plants-daemon", - aliases = { - "//plants-common:plants-common": "common", - }, - deps = all_crate_deps() + ["//plants-common"], -) diff --git a/plants-daemon/Cargo.toml b/plants-daemon/Cargo.toml @@ -1,25 +0,0 @@ -[package] -name = "plants-daemon" -version = "0.1.0" -edition = "2024" - -[[bin]] -name = "plants-daemon" -path = "src/main.rs" - -[dependencies] -common = { path = "../plants-common", package = "plants-common" } -anyhow = "1.0.98" -bluer = { version = "0.17.4", features = ["bluetoothd", "rfcomm"] } -futures = "0.3.31" -regex = "1.10.4" -serde = { version = "1.0.219", features = ["derive"] } -serde_json = "1.0.141" -tokio = { version = "1.46.1", features = ["macros", "rt-multi-thread", "time", "process", "io-util", "sync", "fs", "net"] } -toml = "1.0" -zbus = { version = "5.9.0", features = ["option-as-array"] } -maestro = { git = "https://github.com/qzed/pbpctrl" } -uuid = { version = "=1.23", features = ["v4", "macro-diagnostics"] } -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } -clap = { version = "4.5", features = ["derive"] } diff --git a/plants-daemon/src/airpods.rs b/plants-daemon/src/airpods.rs @@ -1,215 +0,0 @@ -use std::sync::{Arc, Mutex}; -use std::time::Duration; - -use anyhow::Result; -use bluer::{ - DeviceEvent, DeviceProperty, ErrorKind, - rfcomm::{Profile, Role, Stream}, -}; -use futures::StreamExt; -use tokio::{ - io::{AsyncReadExt, AsyncWriteExt}, - time, -}; -use zbus::object_server::InterfaceRef; - -use crate::{ - airpods_consts::{ - AIRPODS_SERVICE, FEATURES_ACK, HANDSHAKE, HANDSHAKE_ACK, REQUEST_NOTIFICATIONS, - SET_SPECIFIC_FEATURES, - }, - daemon_impl::{PlantsDaemon, PlantsDaemonSignals}, - packets::{ - battery::{BatteryPacket, Pod}, - in_ear::InEarPacket, - metadata::MetadataPacket, - }, -}; -use common::status::Status; - -#[derive(Default)] -struct LocalState { - primary: Pod, - // Keep a local copy to track changes and hash, - // but we also sync to the global shared state. - status: Status, -} - -pub async fn run(interface: InterfaceRef<PlantsDaemon>, state: Arc<Mutex<Status>>) -> Result<()> { - // Use a separate session for AirPods scanning - let session = bluer::Session::new().await?; - let adapter = session.default_adapter().await?; - adapter.set_powered(true).await?; - - let profile = Profile { - uuid: AIRPODS_SERVICE, - role: Some(Role::Client), - service: Some(AIRPODS_SERVICE), - ..Default::default() - }; - let mut profile = session.register_profile(profile).await?; - - tracing::info!("Scanning for AirPods"); - let device = get_airpods(&adapter).await?; - tracing::info!("Found AirPods [{}]", device.address()); - - // Connection loop - let device_addr = device.address(); - let device = adapter.device(device_addr)?; // Re-get device to ensure we have valid handle - - // Spawn a task to maintain connection - let device_cl = device.clone(); - tokio::spawn(async move { - // Initial connect attempt - if let Ok(true) = device_cl.is_connected().await { - let _ = device_cl.connect_profile(&AIRPODS_SERVICE).await; - } - - if let Ok(mut events) = device_cl.events().await { - while let Some(event) = events.next().await { - if let DeviceEvent::PropertyChanged(DeviceProperty::Connected(true)) = event { - // Retry connecting profile until success or error is not InProgress - while let Err(err) = device_cl.connect_profile(&AIRPODS_SERVICE).await { - if err.kind == ErrorKind::InProgress { - time::sleep(Duration::from_millis(500)).await; - } else { - // Backoff slightly - time::sleep(Duration::from_millis(100)).await; - } - } - } - } - } - }); - - while let Some(handle) = profile.next().await { - tracing::debug!("AirPods profile connected"); - let mut stream = handle.accept()?; - match handle_connection(&interface, &state, &mut stream).await { - Ok(()) => { - tracing::info!("AirPods connection closed normally"); - } - Err(e) => { - tracing::warn!("AirPods connection error: {}", e); - } - } - - // Clear status on disconnect - { - let mut gs = state.lock().unwrap(); - gs.components = common::status::Components::default(); - gs.ear = common::status::InEar::default(); - gs.metadata = None; - } - - let status = { - let gs = state.lock().unwrap(); - gs.clone() - }; - interface.update(status).await?; - } - - Ok(()) -} - -async fn handle_connection( - interface: &InterfaceRef<PlantsDaemon>, - global_state: &Arc<Mutex<Status>>, - stream: &mut Stream, -) -> Result<()> { - stream.write_all(HANDSHAKE).await?; - - let mut local_state = LocalState::default(); - - loop { - let mut data = Vec::new(); - - loop { - let mut buffer = vec![0; 1024]; - let bytes = stream.read(&mut buffer).await?; - if bytes == 0 { - anyhow::bail!("Stream ended"); - } - data.extend_from_slice(&buffer[..bytes]); - - if bytes < buffer.len() { - break; - } - } - - if data.starts_with(HANDSHAKE_ACK) { - stream.write_all(SET_SPECIFIC_FEATURES).await?; - } else if data.starts_with(FEATURES_ACK) { - stream.write_all(REQUEST_NOTIFICATIONS).await?; - } else { - let hash = local_state.status.hash(); - got_packet(&mut local_state, &data); - - if hash != local_state.status.hash() { - // Update global state - { - let mut gs = global_state.lock().unwrap(); - gs.components = local_state.status.components.clone(); // Assuming Clone is derived - gs.ear = local_state.status.ear.clone(); // Assuming Clone - // Metadata? - if let Some(m) = &local_state.status.metadata { - gs.metadata = Some(common::status::Metadata { - name: m.name.clone(), - model: m.model.clone(), - }); - } - } - - let status = { - let gs = global_state.lock().unwrap(); - gs.clone() - }; - interface.update(status).await?; - } - } - } -} - -fn got_packet(state: &mut LocalState, data: &[u8]) { - if let Some(metadata) = MetadataPacket::parse(data) { - tracing::debug!("Got Metadata: {:?}", metadata); - state.status.metadata = Some(metadata.into()); - } else if let Some(battery) = BatteryPacket::parse(data) { - tracing::debug!("Got Battery: {:?}", battery); - state.primary = battery.primary; - - // Sync local components - if let Some(l) = battery.left { - state.status.components.left = Some(l.into()); - } - if let Some(r) = battery.right { - state.status.components.right = Some(r.into()); - } - if let Some(c) = battery.case { - state.status.components.case = Some(c.into()); - } - } else if let Some(in_ear) = InEarPacket::parse(data) { - tracing::debug!("Got InEar: {:?}", in_ear); - - if let Some([left, right]) = in_ear.get(state.primary) { - state.status.ear.left = left.into(); - state.status.ear.right = right.into(); - } - } -} - -async fn get_airpods(adapter: &bluer::Adapter) -> Result<bluer::Device> { - loop { - let connected = adapter.device_addresses().await?; - for addr in connected { - let device = adapter.device(addr)?; - // Can't always get UUIDs immediately, handling error gracefully - let uuids = device.uuids().await.ok().flatten().unwrap_or_default(); - if uuids.contains(&AIRPODS_SERVICE) { - return Ok(device); - } - } - - time::sleep(Duration::from_secs(5)).await; - } -} diff --git a/plants-daemon/src/airpods_consts.rs b/plants-daemon/src/airpods_consts.rs @@ -1,20 +0,0 @@ -use uuid::{Uuid, uuid}; - -pub const AIRPODS_SERVICE: Uuid = uuid!("74ec2172-0bad-4d01-8f77-997b2be0722a"); - -pub const HANDSHAKE: &[u8] = &[ - 0x00, 0x00, 0x04, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, -]; - -pub const BATTERY_STATUS: &[u8] = &[0x04, 0x00, 0x04, 0x00, 0x04, 0x00]; -pub const METADATA: &[u8] = &[0x04, 0x00, 0x04, 0x00, 0x1d]; -pub const EAR_DETECTION: &[u8] = &[0x04, 0x00, 0x04, 0x00, 0x06, 0x00]; -pub const HANDSHAKE_ACK: &[u8] = &[0x01, 0x00, 0x04, 0x00]; -pub const FEATURES_ACK: &[u8] = &[0x04, 0x00, 0x04, 0x00, 0x2b, 0x00]; - -pub const REQUEST_NOTIFICATIONS: &[u8] = &[ - 0x04, 0x00, 0x04, 0x00, 0x0f, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, -]; -pub const SET_SPECIFIC_FEATURES: &[u8] = &[ - 0x04, 0x00, 0x04, 0x00, 0x4d, 0x00, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, -]; diff --git a/plants-daemon/src/bluetooth.rs b/plants-daemon/src/bluetooth.rs @@ -1,155 +0,0 @@ -use std::sync::{Arc, Mutex}; -use std::time::Duration; - -use anyhow::Result; -use bluer::{AdapterEvent, Address}; -use common::status::{BatteryStatus, GenericDeviceStatus, Status}; -use futures::{StreamExt, pin_mut}; -use tokio::time; -use zbus::object_server::InterfaceRef; - -use crate::{ - config::Config, - daemon_impl::{PlantsDaemon, PlantsDaemonSignals}, -}; - -pub async fn run(interface: InterfaceRef<PlantsDaemon>, state: Arc<Mutex<Status>>) -> Result<()> { - let config = match crate::config::load_config().await { - Ok(c) => c, - Err(e) => { - eprintln!("Failed to load config: {e}"); - tracing::error!("Failed to load config: {}", e); - Config { - devices: std::collections::HashMap::default(), - buds: None, - } - } - }; - - if config.devices.is_empty() { - tracing::info!("No generic devices configured."); - return Ok(()); - } - - let session = bluer::Session::new().await?; - let adapter = session.default_adapter().await?; - adapter.set_powered(true).await?; - - let zbus_conn = zbus::Connection::system().await.ok(); - - // Initial update - update_devices(&adapter, &config, &state, &interface, zbus_conn.as_ref()).await; - - // Listen for events - let events = adapter.events().await?; - pin_mut!(events); - - // Simple debounce: don't update more often than once per second - let mut last_update = time::Instant::now(); - - loop { - tokio::select! { - // Polling interval - () = time::sleep(Duration::from_secs(30)) => { - update_devices(&adapter, &config, &state, &interface, zbus_conn.as_ref()).await; - } - Some(event) = events.next() => { - match event { - AdapterEvent::DeviceAdded(_) | AdapterEvent::DeviceRemoved(_) | AdapterEvent::PropertyChanged(_) => { - if last_update.elapsed() > Duration::from_millis(500) { - update_devices(&adapter, &config, &state, &interface, zbus_conn.as_ref()).await; - last_update = time::Instant::now(); - } - } - } - } - } - } -} - -async fn update_devices( - adapter: &bluer::Adapter, - config: &Config, - state: &Arc<Mutex<Status>>, - interface: &InterfaceRef<PlantsDaemon>, - zbus_conn: Option<&zbus::Connection>, -) { - let mut new_devices = Vec::new(); - - for (name, device_cfg) in &config.devices { - if device_cfg.device_type != "bluetooth" { - continue; - } - - let address: Address = match device_cfg.mac.parse() { - Ok(addr) => addr, - Err(e) => { - tracing::error!("Invalid MAC address for {}: {}", name, e); - continue; - } - }; - - // Check if device is available in adapter - if let Ok(device) = adapter.device(address) { - let is_connected = time::timeout(Duration::from_secs(2), device.is_connected()) - .await - .map(|r| r.unwrap_or(false)) - .unwrap_or(false); - - if is_connected { - let battery_pct = if let Some(conn) = zbus_conn { - get_battery_percentage(adapter.name(), address, conn).await - } else { - None - }; - - if let Some(pct) = battery_pct { - new_devices.push(GenericDeviceStatus { - name: name.clone(), - battery: pct, - text: device_cfg.text.clone(), - status: BatteryStatus::Discharging, - }); - } - } - } - } - - { - let mut status = state.lock().unwrap(); - status.devices = new_devices; - } - - let status = { - let status = state.lock().unwrap(); - status.clone() - }; - - if let Err(e) = interface.update(status).await { - tracing::error!("Failed to update waybar: {}", e); - } -} - -async fn get_battery_percentage( - adapter_name: &str, - address: bluer::Address, - conn: &zbus::Connection, -) -> Option<u8> { - // Construct object path: /org/bluez/{adapter}/dev_{mac_with_underscores} - let addr_str = address.to_string().replace(':', "_"); - let path_str = format!("/org/bluez/{adapter_name}/dev_{addr_str}"); - let path = zbus::zvariant::ObjectPath::try_from(path_str).ok()?; - - // Create a proxy for the Battery1 interface on the device path - let proxy = zbus::Proxy::new(conn, "org.bluez", &path, "org.bluez.Battery1") - .await - .ok()?; - - match proxy.get_property::<u8>("Percentage").await { - Ok(pct) => Some(pct), - Err(e) => { - tracing::debug!("Failed to get battery percentage for {}: {}", address, e); - None - } - } -} diff --git a/plants-daemon/src/config.rs b/plants-daemon/src/config.rs @@ -1,35 +0,0 @@ -use anyhow::{Context, Result}; -use serde::Deserialize; -use std::collections::HashMap; -use tokio::fs; - -#[derive(Deserialize, Debug, Clone)] -pub struct DeviceConfig { - pub mac: String, - pub text: Option<String>, - pub device_type: String, -} - -#[derive(Deserialize, Debug, Clone)] -pub struct BudsConfig { - pub mac: String, -} - -#[derive(Deserialize, Debug, Clone)] -pub struct Config { - pub devices: HashMap<String, DeviceConfig>, - pub buds: Option<BudsConfig>, -} - -pub async fn load_config() -> Result<Config> { - let home = std::env::var("HOME").context("Failed to get HOME env var")?; - let path = format!("{home}/.config/plants/devices.toml"); - - let content = fs::read_to_string(&path) - .await - .context(format!("Failed to read config file: {path}"))?; - - let config: Config = toml::from_str(&content).context("Failed to parse config file")?; - - Ok(config) -} diff --git a/plants-daemon/src/daemon_impl.rs b/plants-daemon/src/daemon_impl.rs @@ -1,10 +0,0 @@ -use common::status::Status; -use zbus::{interface, object_server::SignalEmitter}; - -pub struct PlantsDaemon; - -#[interface(name = "org.mtmn.Plants")] -impl PlantsDaemon { - #[zbus(signal)] - async fn update(emitter: &SignalEmitter<'_>, status: Status) -> zbus::Result<()>; -} diff --git a/plants-daemon/src/main.rs b/plants-daemon/src/main.rs @@ -1,60 +0,0 @@ -use anyhow::Result; - -use zbus::conn; - -use common::status::Status; -use std::sync::{Arc, Mutex}; - -mod airpods; -mod airpods_consts; -mod bluetooth; -mod config; -mod daemon_impl; -mod packets; -mod pbp; -mod pbp_client; - -use crate::daemon_impl::{PlantsDaemon, PlantsDaemonSignals}; - -#[tokio::main] -async fn main() -> Result<()> { - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("debug")), - ) - .init(); - - let conn = conn::Builder::session()? - .name("org.mtmn.Plants")? - .serve_at("/org/mtmn/Plants", PlantsDaemon)? - .build() - .await?; - let interface = conn.object_server().interface("/org/mtmn/Plants").await?; - - let state = Arc::new(Mutex::new(Status::default())); - - PlantsDaemonSignals::update(&interface, Status::default()).await?; - - let bt_state = state.clone(); - let bt_interface = interface.clone(); - tokio::spawn(async move { - if let Err(e) = bluetooth::run(bt_interface, bt_state).await { - tracing::error!("Bluetooth generic error: {}", e); - } - }); - - let ap_state = state.clone(); - let ap_interface = interface.clone(); - tokio::spawn(async move { - if let Err(e) = airpods::run(ap_interface, ap_state).await { - tracing::error!("AirPods error: {}", e); - } - }); - - let pbp_state = state.clone(); - let pbp_interface = interface.clone(); - pbp::run(pbp_interface, pbp_state).await?; - - Ok(()) -} diff --git a/plants-daemon/src/packets/battery.rs b/plants-daemon/src/packets/battery.rs @@ -1,132 +0,0 @@ -use common::status; - -use crate::airpods_consts::BATTERY_STATUS; - -#[derive(Default, Debug)] -pub struct BatteryPacket { - pub left: Option<ComponentStatus>, - pub right: Option<ComponentStatus>, - pub case: Option<ComponentStatus>, - pub primary: Pod, -} - -#[derive(Debug, Clone, Copy)] -pub struct ComponentStatus { - pub level: u8, - pub status: BatteryStatus, -} - -#[derive(Default, Debug, PartialEq, Eq, Clone, Copy)] -pub enum Pod { - Left, - Right, - #[default] - None, -} - -#[derive(Debug, Clone, Copy)] -#[repr(u8)] -pub enum BatteryStatus { - Charging = 0x01, - Discharging = 0x02, - Disconnected = 0x04, -} - -#[derive(Debug)] -#[repr(u8)] -enum Component { - Right = 0x02, - Left = 0x04, - Case = 0x08, -} - -impl BatteryPacket { - pub fn parse(raw: &[u8]) -> Option<Self> { - if !raw.starts_with(BATTERY_STATUS) || raw.len() <= 6 { - return None; - } - - let components = raw[6] as usize; - if components > 3 || raw.len() != 7 + 5 * components { - return None; - } - - let mut out = Self::default(); - for i in 0..components { - let i = 7 + (5 * i); - if raw[i + 1] != 0x01 || raw[i + 4] != 0x01 { - continue; - } - - let component_type = Component::from(raw[i])?; - let level = raw[i + 2]; - let status = BatteryStatus::from(raw[i + 3])?; - - if matches!(out.primary, Pod::None) { - out.primary = component_type.as_pod(); - } - - let component_status = Some(ComponentStatus { level, status }); - match component_type { - Component::Left => out.left = component_status, - Component::Right => out.right = component_status, - Component::Case => out.case = component_status, - } - } - - Some(out) - } - - pub fn as_arr(&self) -> [&Option<ComponentStatus>; 3] { - [&self.left, &self.right, &self.case] - } -} - -impl Component { - pub fn from(byte: u8) -> Option<Self> { - match byte { - 0x02 => Some(Self::Right), - 0x04 => Some(Self::Left), - 0x08 => Some(Self::Case), - _ => None, - } - } - - pub fn as_pod(&self) -> Pod { - match self { - Component::Left => Pod::Left, - Component::Right => Pod::Right, - Component::Case => Pod::None, - } - } -} - -impl BatteryStatus { - pub fn from(byte: u8) -> Option<Self> { - match byte { - 0x01 => Some(Self::Charging), - 0x02 => Some(Self::Discharging), - 0x04 => Some(Self::Disconnected), - _ => None, - } - } -} - -impl From<ComponentStatus> for status::ComponentStatus { - fn from(val: ComponentStatus) -> status::ComponentStatus { - status::ComponentStatus { - level: val.level, - status: val.status.into(), - } - } -} - -impl From<BatteryStatus> for status::BatteryStatus { - fn from(val: BatteryStatus) -> status::BatteryStatus { - match val { - BatteryStatus::Charging => status::BatteryStatus::Charging, - BatteryStatus::Discharging => status::BatteryStatus::Discharging, - BatteryStatus::Disconnected => status::BatteryStatus::Disconnected, - } - } -} diff --git a/plants-daemon/src/packets/in_ear.rs b/plants-daemon/src/packets/in_ear.rs @@ -1,61 +0,0 @@ -use common::status; - -use crate::{airpods_consts::EAR_DETECTION, packets::battery::Pod}; - -#[derive(Default, Debug)] -pub struct InEarPacket { - pub primary: EarStatus, - pub secondary: EarStatus, -} - -#[derive(Default, Debug, Clone, Copy)] -pub enum EarStatus { - InEar = 0x00, - NotInEar = 0x01, - InCase = 0x02, - #[default] - Disconnected, -} - -impl InEarPacket { - pub fn parse(bytes: &[u8]) -> Option<Self> { - if bytes.len() != 8 || !bytes.starts_with(EAR_DETECTION) { - return None; - } - - Some(InEarPacket { - primary: EarStatus::from(bytes[6]), - secondary: EarStatus::from(bytes[7]), - }) - } - - pub fn get(&self, primary: Pod) -> Option<[EarStatus; 2]> { - Some(match primary { - Pod::Left => [self.primary, self.secondary], - Pod::Right => [self.secondary, self.primary], - Pod::None => return None, - }) - } -} - -impl EarStatus { - pub fn from(byte: u8) -> Self { - match byte { - 0x00 => EarStatus::InEar, - 0x01 => EarStatus::NotInEar, - 0x02 => EarStatus::InCase, - _ => EarStatus::Disconnected, - } - } -} - -impl From<EarStatus> for status::EarStatus { - fn from(val: EarStatus) -> status::EarStatus { - match val { - EarStatus::InEar => status::EarStatus::InEar, - EarStatus::NotInEar => status::EarStatus::NotInEar, - EarStatus::InCase => status::EarStatus::InCase, - EarStatus::Disconnected => status::EarStatus::Disconnected, - } - } -} diff --git a/plants-daemon/src/packets/metadata.rs b/plants-daemon/src/packets/metadata.rs @@ -1,49 +0,0 @@ -use common::status; - -use crate::airpods_consts::METADATA; - -#[derive(Default, Debug)] -pub struct MetadataPacket { - pub device_name: String, - pub model_number: String, - pub manufacturer: String, -} - -impl MetadataPacket { - pub fn parse(data: &[u8]) -> Option<Self> { - if data.len() < 11 || !data.starts_with(METADATA) { - return None; - } - - let data = &data[11..]; - let mut start = 0; - let mut idx = 0; - - let mut read_string = || { - start = idx; - while idx < data.len() && data[idx] != 0x00 { - idx += 1; - } - - let out = String::from_utf8_lossy(&data[start..idx]).to_string(); - idx += 1; - - out - }; - - Some(Self { - device_name: read_string(), - model_number: read_string(), - manufacturer: read_string(), - }) - } -} - -impl From<MetadataPacket> for status::Metadata { - fn from(val: MetadataPacket) -> status::Metadata { - status::Metadata { - name: val.device_name, - model: val.model_number, - } - } -} diff --git a/plants-daemon/src/packets/mod.rs b/plants-daemon/src/packets/mod.rs @@ -1,5 +0,0 @@ -#![allow(dead_code)] - -pub mod battery; -pub mod in_ear; -pub mod metadata; diff --git a/plants-daemon/src/pbp.rs b/plants-daemon/src/pbp.rs @@ -1,129 +0,0 @@ -use std::sync::{Arc, Mutex}; -use std::time::Duration; - -use anyhow::Result; -use common::status::Status; -use tokio::time; -use zbus::object_server::InterfaceRef; - -use crate::daemon_impl::{PlantsDaemon, PlantsDaemonSignals}; - -pub async fn run(interface: InterfaceRef<PlantsDaemon>, state: Arc<Mutex<Status>>) -> Result<()> { - let mut session: Option<bluer::Session> = None; - - // Load config once to check for buds - let config = crate::config::load_config().await.ok(); - let target_mac = if let Some(c) = &config { - if let Some(buds) = &c.buds { - buds.mac.parse::<bluer::Address>().ok() - } else { - None - } - } else { - None - }; - - if target_mac.is_some() { - session = bluer::Session::new().await.ok(); - } - - loop { - let mut should_run = true; - - if let Some(mac) = target_mac { - should_run = false; - if let Some(sess) = &session { - // Check if device is connected with timeouts - let is_connected = async { - let Ok(Ok(adapter)) = - time::timeout(Duration::from_secs(2), sess.default_adapter()).await - else { - return false; - }; - - let Ok(device) = adapter.device(mac) else { - return false; - }; - - matches!( - time::timeout(Duration::from_secs(2), device.is_connected()).await, - Ok(Ok(true)) - ) - } - .await; - - if is_connected { - should_run = true; - } - } else if let Ok(Ok(s)) = - time::timeout(Duration::from_secs(2), bluer::Session::new()).await - { - session = Some(s); - } - } - - if should_run { - if let (Some(sess), Some(mac)) = (&session, target_mac) { - // Keep trying to stream as long as connected - if let Ok(adapter) = sess.default_adapter().await { - let res = crate::pbp_client::stream_pbp_stats( - sess, - &adapter, - bluer::Address(*mac), - { - let state = state.clone(); - let interface = interface.clone(); - move |new_status| { - { - let mut status = state.lock().unwrap(); - status.components = new_status.components; - status.ear = new_status.ear; - } - - let status = { - let status = state.lock().unwrap(); - status.clone() - }; - - // We need to spawn this because callback is sync but update is async - let interface = interface.clone(); - tokio::spawn(async move { - if let Err(e) = interface.update(status).await { - tracing::error!("Failed to update plants: {}", e); - } - }); - } - }, - ) - .await; - - if let Err(e) = res { - tracing::error!("PBP stream error: {}", e); - } - } - } - } else { - // If we are skipping, ensure we don't show stale info - { - let mut status = state.lock().unwrap(); - // Only clear if metadata is None (implying it might be PBP data). - if status.metadata.is_none() { - status.components = common::status::Components::default(); - status.ear = common::status::InEar::default(); - } - } - // Trigger update to clear PBP info from bar if present - let status = { - let status = state.lock().unwrap(); - status.clone() - }; - - if let Err(e) = interface.update(status).await { - tracing::error!("Failed to update plants: {}", e); - } - } - - // Wait before retrying (e.g. if disconnected or error) - time::sleep(Duration::from_secs(5)).await; - } -} diff --git a/plants-daemon/src/pbp_client.rs b/plants-daemon/src/pbp_client.rs @@ -1,188 +0,0 @@ -use anyhow::Result; -use bluer::rfcomm::{Profile, ProfileHandle, ReqError, Role, Stream}; -use bluer::{Adapter, Address, Device, Session}; -use common::status::{BatteryStatus, ComponentStatus, Components, EarStatus, Status}; -use futures::StreamExt; -use maestro::protocol::codec::Codec; -use maestro::protocol::utils; -use maestro::pwrpc::client::Client; -use maestro::service::MaestroService; -use std::time::Duration; - -pub async fn stream_pbp_stats<F>( - session: &Session, - adapter: &Adapter, - mac: Address, - callback: F, -) -> Result<()> -where - F: Fn(Status) + Send + Sync + 'static, -{ - let dev = adapter.device(mac)?; - tracing::debug!("Connecting to PBP RFCOMM at {}", mac); - - // Connect RFCOMM - let stream = connect_maestro_rfcomm(session, &dev).await?; - tracing::debug!("RFCOMM connected"); - - // Setup Codec - let codec = Codec::new(); - let stream = codec.wrap(stream); - - // Setup RPC Client - let mut client = Client::new(stream); - let handle = client.handle(); - - // Resolve channel first - pbpctrl does this before client.run() - let channel = utils::resolve_channel(&mut client).await?; - tracing::debug!("Maestro channel resolved: {}", channel); - - let (tx, mut rx) = tokio::sync::mpsc::channel(1); - - let task = async move { - let mut service = MaestroService::new(handle, channel); - let mut call = service.subscribe_to_runtime_info()?; - tracing::debug!("Subscribed to RuntimeInfo"); - - let mut stream = call.stream(); - while let Some(msg) = stream.next().await { - let info = msg?; - tracing::trace!("Received RuntimeInfo update: {:?}", info); - if tx.send(info).await.is_err() { - break; - } - } - - Ok::<_, anyhow::Error>(()) - }; - - tokio::select! { - res = client.run() => { - tracing::warn!("Client run loop terminated: {:?}", res); - res?; - anyhow::bail!("client terminated unexpectedly"); - }, - res = task => { - tracing::warn!("Subscription task terminated: {:?}", res); - res?; - } - () = async { - while let Some(info) = rx.recv().await { - callback(runtime_info_to_status(info)); - } - } => {} - } - - Ok(()) -} - -fn runtime_info_to_status(info: maestro::protocol::types::RuntimeInfo) -> Status { - let mut components = Components::default(); - let mut ear = common::status::InEar::default(); - - // Map Case - if let Some(c) = info.battery_info.as_ref().and_then(|b| b.case.as_ref()) { - components.case = Some(ComponentStatus { - level: u8::try_from(c.level).unwrap_or(0), - status: if c.state == 2 { - BatteryStatus::Charging - } else { - BatteryStatus::Discharging - }, - }); - } - - // Map Left - if let Some(l) = info.battery_info.as_ref().and_then(|b| b.left.as_ref()) { - components.left = Some(ComponentStatus { - level: u8::try_from(l.level).unwrap_or(0), - status: if l.state == 2 { - BatteryStatus::Charging - } else { - BatteryStatus::Discharging - }, - }); - if let Some(placement) = &info.placement { - ear.left = if placement.left_bud_in_case { - EarStatus::InCase - } else { - EarStatus::InEar - }; - } - } - - // Map Right - if let Some(r) = info.battery_info.as_ref().and_then(|b| b.right.as_ref()) { - components.right = Some(ComponentStatus { - level: u8::try_from(r.level).unwrap_or(0), - status: if r.state == 2 { - BatteryStatus::Charging - } else { - BatteryStatus::Discharging - }, - }); - if let Some(placement) = &info.placement { - ear.right = if placement.right_bud_in_case { - EarStatus::InCase - } else { - EarStatus::InEar - }; - } - } - - Status { - metadata: None, - components, - ear, - devices: Vec::new(), - } -} - -async fn connect_maestro_rfcomm(session: &Session, dev: &Device) -> Result<Stream> { - let maestro_profile = Profile { - uuid: maestro::UUID, - role: Some(Role::Client), - require_authentication: Some(false), - require_authorization: Some(false), - auto_connect: Some(false), - ..Default::default() - }; - - let mut handle = session.register_profile(maestro_profile).await?; - - let stream = tokio::try_join!( - try_connect_profile(dev), - handle_requests_for_profile(&mut handle, dev.address()), - )? - .1; - - Ok(stream) -} - -async fn try_connect_profile(dev: &Device) -> Result<()> { - const RETRY_TIMEOUT: Duration = Duration::from_secs(1); - const MAX_TRIES: u32 = 3; - - let mut i = 0; - while let Err(err) = dev.connect_profile(&maestro::UUID).await { - if i >= MAX_TRIES { - return Err(err.into()); - } - i += 1; - tokio::time::sleep(RETRY_TIMEOUT).await; - } - Ok(()) -} - -async fn handle_requests_for_profile( - handle: &mut ProfileHandle, - address: Address, -) -> Result<Stream> { - while let Some(req) = handle.next().await { - if req.device() == address { - return Ok(req.accept()?); - } - req.reject(ReqError::Rejected); - } - anyhow::bail!("profile terminated without requests") -}