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:
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")
-}