From 2ce5aceae0d27a55735f9993cc129c08870668bb Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Wed, 1 Apr 2026 12:23:04 +0200 Subject: [PATCH] implemented library calls instead of cli calls --- Cargo.lock | 850 ++++++++++++++++++++++++++++++++++++++-- Cargo.toml | 10 +- README.md | 3 +- example.config.toml | 46 +++ src/config.rs | 9 +- src/daemon.rs | 20 +- src/main.rs | 53 ++- src/modules/audio.rs | 293 ++++++++++---- src/modules/bt.rs | 634 ++++++++++++++++++++++++++---- src/modules/buds.rs | 136 ------- src/modules/disk.rs | 2 +- src/modules/game.rs | 22 +- src/modules/hardware.rs | 48 ++- src/modules/mod.rs | 1 - src/modules/network.rs | 73 ++-- src/modules/power.rs | 2 +- src/modules/sys.rs | 2 +- src/output.rs | 11 + src/state.rs | 34 ++ src/test_pbpctrl.rs | 5 + src/utils.rs | 44 +-- 21 files changed, 1874 insertions(+), 424 deletions(-) create mode 100644 example.config.toml delete mode 100644 src/modules/buds.rs create mode 100644 src/test_pbpctrl.rs diff --git a/Cargo.lock b/Cargo.lock index 1c8620b..b63ee56 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,9 +28,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" @@ -47,7 +47,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -58,7 +58,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -67,6 +67,18 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "bitflags" version = "2.11.0" @@ -82,6 +94,47 @@ dependencies = [ "objc2", ] +[[package]] +name = "bluer" +version = "0.17.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af68112f5c60196495c8b0eea68349817855f565df5b04b2477916d09fb1a901" +dependencies = [ + "custom_debug", + "dbus", + "dbus-crossroads", + "dbus-tokio", + "displaydoc", + "futures", + "hex", + "lazy_static", + "libc", + "log", + "macaddr", + "nix 0.29.0", + "num-derive", + "num-traits", + "pin-project", + "serde", + "serde_json", + "strum", + "tokio", + "tokio-stream", + "uuid", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + [[package]] name = "cfg-if" version = "1.0.4" @@ -136,9 +189,9 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "ctrlc" @@ -147,8 +200,98 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" dependencies = [ "dispatch2", - "nix", - "windows-sys", + "nix 0.31.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "custom_debug" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da7d1ad9567b3e11e877f1d7a0fa0360f04162f94965fc4448fbed41a65298e" +dependencies = [ + "custom_debug_derive", +] + +[[package]] +name = "custom_debug_derive" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a707ceda8652f6c7624f2be725652e9524c815bf3b9d55a0b2320be2303f9c11" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "dbus" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b3aa68d7e7abee336255bd7248ea965cc393f3e70411135a6f6a4b651345d4" +dependencies = [ + "futures-channel", + "futures-util", + "libc", + "libdbus-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "dbus-crossroads" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64bff0bd181fba667660276c6b7ebdc50cff37ce593e7adf9e734f89c8f444e8" +dependencies = [ + "dbus", +] + +[[package]] +name = "dbus-tokio" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007688d459bc677131c063a3a77fb899526e17b7980f390b69644bdbc41fad13" +dependencies = [ + "dbus", + "libc", + "tokio", ] [[package]] @@ -163,6 +306,23 @@ dependencies = [ "objc2", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "equivalent" version = "1.0.2" @@ -176,7 +336,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -185,29 +345,136 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "fluxo-rs" version = "0.2.0" dependencies = [ "anyhow", + "bluer", "clap", "ctrlc", + "futures", + "libpulse-binding", + "maestro", + "nix 0.29.0", "regex", "serde", "serde_json", "sysinfo", "tempfile", + "tokio", + "tokio-util", "toml", "tracing", "tracing-subscriber", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -242,12 +509,24 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "indexmap" version = "2.13.0" @@ -267,10 +546,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] -name = "itoa" -version = "1.0.17" +name = "itertools" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "797146bb2677299a1eb6b7b50a890f4c361b29ef967addf5b2fa45dae1bb6d7d" +dependencies = [ + "once_cell", + "wasm-bindgen", +] [[package]] name = "lazy_static" @@ -290,6 +588,42 @@ version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libpulse-binding" +version = "2.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "909eb3049e16e373680fe65afe6e2a722ace06b671250cc4849557bc57d6a397" +dependencies = [ + "bitflags", + "libc", + "libpulse-sys", + "num-derive", + "num-traits", + "winapi", +] + +[[package]] +name = "libpulse-sys" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d74371848b22e989f829cc1621d2ebd74960711557d8b45cfe740f60d0a05e61" +dependencies = [ + "libc", + "num-derive", + "num-traits", + "pkg-config", + "winapi", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -302,6 +636,29 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "macaddr" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baee0bbc17ce759db233beb01648088061bf678383130602a298e6998eedb2d8" + +[[package]] +name = "maestro" +version = "0.1.5" +source = "git+https://github.com/qzed/pbpctrl#2620367a41f92135059f637d92a2e4f427abb44e" +dependencies = [ + "arrayvec", + "bytes", + "futures", + "num_enum", + "prost", + "prost-build", + "tokio", + "tokio-util", + "tracing", + "uuid", +] + [[package]] name = "matchers" version = "0.2.0" @@ -317,6 +674,45 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nix" version = "0.31.2" @@ -344,7 +740,49 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -393,12 +831,49 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + [[package]] name = "prettyplease" version = "0.2.37" @@ -409,6 +884,15 @@ dependencies = [ "syn", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -418,6 +902,57 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prost" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost-types" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +dependencies = [ + "prost", +] + [[package]] name = "quote" version = "1.0.45" @@ -472,9 +1007,15 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "semver" version = "1.0.27" @@ -526,9 +1067,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98" dependencies = [ "serde_core", ] @@ -542,18 +1083,56 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "syn" version = "2.0.117" @@ -565,6 +1144,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sysinfo" version = "0.38.4" @@ -589,7 +1179,7 @@ dependencies = [ "getrandom", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -602,10 +1192,60 @@ dependencies = [ ] [[package]] -name = "toml" -version = "1.0.6+spec-1.1.0" +name = "tokio" +version = "1.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "399b1124a3c9e16766831c6bba21e50192572cdd98706ea114f9502509686ffc" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8195ca05e4eb728f4ba94f3e3291661320af739c4e43779cbdfae82ab239fcc" dependencies = [ "indexmap", "serde_core", @@ -618,27 +1258,39 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.0.0+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" dependencies = [ "serde_core", ] [[package]] -name = "toml_parser" -version = "1.0.9+spec-1.1.0" +name = "toml_edit" +version = "0.25.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.0.6+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed" [[package]] name = "tracing" @@ -719,12 +1371,29 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.2+wasi-0.2.9" @@ -743,6 +1412,51 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dc0882f7b5bb01ae8c5215a1230832694481c1a4be062fd410e12ea3da5b631" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75973d3066e01d035dbedaad2864c398df42f8dd7b1ea057c35b8407c015b537" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91af5e4be765819e0bcfee7322c14374dc821e35e72fa663a830bbc7dc199eac" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9bf0406a78f02f336bf1e451799cca198e8acde4ffa278f0fb20487b150a633" +dependencies = [ + "unicode-ident", +] + [[package]] name = "wasm-encoder" version = "0.244.0" @@ -900,6 +1614,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -909,6 +1632,22 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + [[package]] name = "windows-threading" version = "0.2.1" @@ -919,10 +1658,61 @@ dependencies = [ ] [[package]] -name = "winnow" -version = "0.7.15" +name = "windows_aarch64_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +dependencies = [ + "memchr", +] [[package]] name = "wit-bindgen" diff --git a/Cargo.toml b/Cargo.toml index dc6f275..c6a5c20 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,13 +7,21 @@ edition = "2024" anyhow = "1.0.102" clap = { version = "4.6.0", features = ["derive"] } ctrlc = "3" -regex = "1.10" +regex = "1.12" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" sysinfo = "0.38.4" toml = "1.0.6" tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } +maestro = { git = "https://github.com/qzed/pbpctrl", package = "maestro" } +bluer = { version = "0.17", features = ["bluetoothd", "rfcomm", "id"] } +tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] } +tokio-util = { version = "0.7", features = ["codec"] } +futures = "0.3" +libpulse-binding = "2.26" +nix = { version = "0.29", features = ["net"] } [dev-dependencies] + tempfile = "3" diff --git a/README.md b/README.md index 37c2f69..7b225e9 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,7 @@ This approach eliminates process spawning overhead and temporary file locking, r | `pool` | Aggregate storage (btrfs) | `{used}`, `{total}` | | `vol` | Audio output volume | `{name}`, `{volume}`, `{icon}` | | `mic` | Audio input volume | `{name}`, `{volume}`, `{icon}` | -| `bt` | Bluetooth status | `{alias}` | -| `buds` | Pixel Buds Pro control | `{left}`, `{right}`, `{anc}` | +| `bt` | Bluetooth status & plugins | `{alias}`, `{mac}`, `{left}`, `{right}`, `{anc}` | | `power` | Battery and AC status | `{percentage}`, `{icon}` | | `game` | Hyprland gamemode status | active/inactive icon strings | diff --git a/example.config.toml b/example.config.toml new file mode 100644 index 0000000..dd140fe --- /dev/null +++ b/example.config.toml @@ -0,0 +1,46 @@ +# Fluxo configuration example + +[general] +menu_command = "fuzzel --dmenu --prompt '{prompt}'" + +[network] +format = "{interface} ({ip}):  {rx:>5.2} MB/s  {tx:>5.2} MB/s" + +[cpu] +format = "CPU: {usage:>4.1}% {temp:>4.1}C" + +[memory] +format = "{used:>5.2}/{total:>5.2}GB" + +[gpu] +format_amd = "AMD: {usage:>3.0}% {vram_used:>4.1}/{vram_total:>4.1}GB {temp:>4.1}C" +format_intel = "iGPU: {usage:>3.0}%" +format_nvidia = "NV: {usage:>3.0}% {vram_used:>4.1}/{vram_total:>4.1}GB {temp:>4.1}C" + +[sys] +format = "UP: {uptime} | LOAD: {load1:>4.2} {load5:>4.2} {load15:>4.2}" + +[disk] +format = "{mount} {used:>5.1}/{total:>5.1}G" + +[pool] +format = "{used:>4.0}G / {total:>4.0}G" + +[power] +format = "{percentage:>3}% {icon}" + +[bt] +format_connected = "{alias} 󰂰" +format_plugin = "{alias} [{left}|{right}] {anc} 󰂰" +format_disconnected = "󰂯" +format_disabled = "󰂲 Off" + +[audio] +format_sink_unmuted = "{name} {volume:>3}% {icon}" +format_sink_muted = "{name} {icon}" +format_source_unmuted = "{name} {volume:>3}% {icon}" +format_source_muted = "{name} {icon}" + +[game] +format_active = "󰊖" +format_inactive = "" diff --git a/src/config.rs b/src/config.rs index 638384b..c1675d9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -160,7 +160,6 @@ impl Default for PowerConfig { #[derive(Deserialize)] pub struct BudsConfig { - pub mac: String, pub format: String, pub format_disconnected: String, } @@ -168,7 +167,6 @@ pub struct BudsConfig { impl Default for BudsConfig { fn default() -> Self { Self { - mac: "B4:23:A2:09:D3:53".to_string(), format: "{left} | {right} | {anc}".to_string(), format_disconnected: "".to_string(), } @@ -197,6 +195,7 @@ impl Default for AudioConfig { #[derive(Deserialize)] pub struct BtConfig { pub format_connected: String, + pub format_plugin: String, pub format_disconnected: String, pub format_disabled: String, } @@ -205,6 +204,7 @@ impl Default for BtConfig { fn default() -> Self { Self { format_connected: "{alias} 󰂰".to_string(), + format_plugin: "{alias} [{left}|{right}] {anc} 󰂰".to_string(), format_disconnected: "󰂯".to_string(), format_disabled: "󰂲 Off".to_string(), } @@ -297,6 +297,11 @@ impl Config { &["name", "icon"], ); validate_format("bt.connected", &self.bt.format_connected, &["alias"]); + validate_format( + "bt.plugin", + &self.bt.format_plugin, + &["alias", "left", "right", "anc", "mac"], + ); } } diff --git a/src/daemon.rs b/src/daemon.rs index 243ba42..ca15137 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1,6 +1,8 @@ use crate::config::Config; use crate::ipc::socket_path; use crate::modules::WaybarModule; +use crate::modules::audio::AudioDaemon; +use crate::modules::bt::BtDaemon; use crate::modules::hardware::HardwareDaemon; use crate::modules::network::NetworkDaemon; use crate::state::{AppState, SharedState}; @@ -59,9 +61,15 @@ pub fn run_daemon(config_path: Option) -> Result<()> { info!("Starting background polling thread"); let mut network_daemon = NetworkDaemon::new(); let mut hardware_daemon = HardwareDaemon::new(); + let mut bt_daemon = BtDaemon::new(); + + let audio_daemon = AudioDaemon::new(); + audio_daemon.start(Arc::clone(&poll_state)); + while poll_running.load(Ordering::SeqCst) { network_daemon.poll(Arc::clone(&poll_state)); hardware_daemon.poll(Arc::clone(&poll_state)); + bt_daemon.poll(Arc::clone(&poll_state)); thread::sleep(Duration::from_secs(1)); } }); @@ -111,7 +119,16 @@ pub fn run_daemon(config_path: Option) -> Result<()> { let response = handle_request(module_name, &parts[1..], &state_clone, &config_clone); if let Err(e) = stream.write_all(response.as_bytes()) { - error!("Failed to write IPC response: {}", e); + if e.kind() == std::io::ErrorKind::BrokenPipe + || e.kind() == std::io::ErrorKind::ConnectionReset + { + debug!( + "IPC client disconnected before response could be sent: {}", + e + ); + } else { + error!("Failed to write IPC response: {}", e); + } } let _ = stream.shutdown(Shutdown::Write); } @@ -160,7 +177,6 @@ fn handle_request( "gpu" => crate::modules::gpu::GpuModule.run(&config, state, args), "sys" => crate::modules::sys::SysModule.run(&config, state, args), "bt" | "bluetooth" => crate::modules::bt::BtModule.run(&config, state, args), - "buds" => crate::modules::buds::BudsModule.run(&config, state, args), "power" => crate::modules::power::PowerModule.run(&config, state, args), "game" => crate::modules::game::GameModule.run(&config, state, args), _ => { diff --git a/src/main.rs b/src/main.rs index cb16a3c..8ffc44c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -71,11 +71,6 @@ enum Commands { #[arg(default_value = "show")] action: String, }, - /// Pixel Buds Pro ANC and Battery - Buds { - #[arg(default_value = "show")] - action: String, - }, /// System power and battery status Power, /// Hyprland gamemode status @@ -125,6 +120,27 @@ fn main() { // Client-side execution of the menu let config = config::load_config(None); + let mut items = Vec::new(); + + // If connected, show plugin modes and disconnect + if let Ok(json_str) = ipc::request_data("bt", &["get_modes"]) { + if let Ok(val) = serde_json::from_str::(&json_str) { + if let Some(modes_str) = val.get("text").and_then(|t| t.as_str()) { + if !modes_str.is_empty() { + for mode in modes_str.lines() { + items.push(format!("Mode: {}", mode)); + } + } + } + } + } + + if !items.is_empty() { + items.push("Disconnect".to_string()); + } + + items.push("--- Connect Device ---".to_string()); + let devices_out = match std::process::Command::new("bluetoothctl") .args(["devices"]) .output() @@ -137,7 +153,6 @@ fn main() { }; let stdout = String::from_utf8_lossy(&devices_out.stdout); - let mut items = Vec::new(); for line in stdout.lines() { if line.starts_with("Device ") { let parts: Vec<&str> = line.splitn(3, ' ').collect(); @@ -149,23 +164,31 @@ fn main() { if !items.is_empty() { if let Ok(selected) = - utils::show_menu("Connect BT: ", &items, &config.general.menu_command) - && let Some(mac_start) = selected.rfind('(') - && let Some(mac_end) = selected.rfind(')') + utils::show_menu("BT Menu: ", &items, &config.general.menu_command) { - let mac = &selected[mac_start + 1..mac_end]; - let _ = std::process::Command::new("bluetoothctl") - .args(["connect", mac]) - .status(); + if selected.starts_with("Mode: ") { + let mode = &selected[6..]; + handle_ipc_response(ipc::request_data("bt", &["set_mode", mode])); + } else if selected == "Disconnect" { + handle_ipc_response(ipc::request_data("bt", &["disconnect"])); + } else if selected == "--- Connect Device ---" { + // Do nothing + } else if let Some(mac_start) = selected.rfind('(') + && let Some(mac_end) = selected.rfind(')') + { + let mac = &selected[mac_start + 1..mac_end]; + let _ = std::process::Command::new("bluetoothctl") + .args(["connect", mac]) + .status(); + } } } else { - info!("No paired Bluetooth devices found."); + info!("No Bluetooth options found."); } return; } handle_ipc_response(ipc::request_data("bt", &[action])); } - Commands::Buds { action } => handle_ipc_response(ipc::request_data("buds", &[action])), Commands::Power => handle_ipc_response(ipc::request_data("power", &[])), Commands::Game => handle_ipc_response(ipc::request_data("game", &[])), } diff --git a/src/modules/audio.rs b/src/modules/audio.rs index 2d3393d..dae42c6 100644 --- a/src/modules/audio.rs +++ b/src/modules/audio.rs @@ -2,55 +2,220 @@ use crate::config::Config; use crate::modules::WaybarModule; use crate::output::WaybarOutput; use crate::state::SharedState; -use crate::utils::{TokenValue, format_template, run_command}; +use crate::utils::{TokenValue, format_template}; use anyhow::{Result, anyhow}; +use libpulse_binding::callbacks::ListResult; +use libpulse_binding::context::subscribe::{Facility, InterestMaskSet}; +use libpulse_binding::context::{Context, FlagSet as ContextFlag}; +use libpulse_binding::mainloop::threaded::Mainloop as ThreadedMainloop; +use libpulse_binding::volume::Volume; use std::process::Command; +use std::sync::Arc; +use tracing::error; + +pub struct AudioDaemon; + +impl AudioDaemon { + pub fn new() -> Self { + Self + } + + pub fn start(&self, state: SharedState) { + let state_arc = Arc::clone(&state); + + std::thread::spawn(move || { + let mut mainloop = + ThreadedMainloop::new().expect("Failed to create pulse threaded mainloop"); + + let mut context = + Context::new(&mainloop, "fluxo-rs").expect("Failed to create pulse context"); + + context + .connect(None, ContextFlag::NOFLAGS, None) + .expect("Failed to connect pulse context"); + + mainloop.start().expect("Failed to start pulse mainloop"); + + mainloop.lock(); + + // Wait for context to be ready + loop { + match context.get_state() { + libpulse_binding::context::State::Ready => break, + libpulse_binding::context::State::Failed + | libpulse_binding::context::State::Terminated => { + error!("Pulse context failed or terminated"); + mainloop.unlock(); + return; + } + _ => { + mainloop.unlock(); + std::thread::sleep(Duration::from_millis(50)); + mainloop.lock(); + } + } + } + + // Initial fetch + let _ = fetch_audio_data_sync(&mut context, &state_arc); + + // Subscribe to events + let interest = + InterestMaskSet::SINK | InterestMaskSet::SOURCE | InterestMaskSet::SERVER; + context.subscribe(interest, |_| {}); + + let (tx, rx) = std::sync::mpsc::channel(); + + context.set_subscribe_callback(Some(Box::new(move |facility, _operation, _index| { + match facility { + Some(Facility::Sink) | Some(Facility::Source) | Some(Facility::Server) => { + let _ = tx.send(()); + } + _ => {} + } + }))); + + mainloop.unlock(); + + // Background polling loop driven by events or a 2s fallback timeout + loop { + let _ = rx.recv_timeout(Duration::from_secs(2)); + { + mainloop.lock(); + let _ = fetch_audio_data_sync(&mut context, &state_arc); + mainloop.unlock(); + } + } + }); + } +} + +use std::time::Duration; + +fn fetch_audio_data_sync(context: &mut Context, state: &SharedState) -> Result<()> { + let state_inner = Arc::clone(state); + + // We fetch all sinks and sources, and also server info to know defaults. + // The order doesn't strictly matter as long as we update correctly. + + let st_server = Arc::clone(&state_inner); + context.introspect().get_server_info(move |info| { + let mut lock = st_server.write().unwrap(); + lock.audio.sink.name = info + .default_sink_name + .as_ref() + .map(|s| s.to_string()) + .unwrap_or_default(); + lock.audio.source.name = info + .default_source_name + .as_ref() + .map(|s| s.to_string()) + .unwrap_or_default(); + }); + + let st_sink = Arc::clone(&state_inner); + context.introspect().get_sink_info_list(move |res| { + if let ListResult::Item(item) = res { + let mut lock = st_sink.write().unwrap(); + // If this matches our default sink name, or if we don't have details for any yet + let is_default = item + .name + .as_ref() + .map(|s| s.to_string() == lock.audio.sink.name) + .unwrap_or(false); + if is_default { + lock.audio.sink.description = item + .description + .as_ref() + .map(|s| s.to_string()) + .unwrap_or_default(); + lock.audio.sink.volume = + ((item.volume.avg().0 as f64 / Volume::NORMAL.0 as f64) * 100.0).round() as u8; + lock.audio.sink.muted = item.mute; + } + } + }); + + let st_source = Arc::clone(&state_inner); + context.introspect().get_source_info_list(move |res| { + if let ListResult::Item(item) = res { + let mut lock = st_source.write().unwrap(); + let is_default = item + .name + .as_ref() + .map(|s| s.to_string() == lock.audio.source.name) + .unwrap_or(false); + if is_default { + lock.audio.source.description = item + .description + .as_ref() + .map(|s| s.to_string()) + .unwrap_or_default(); + lock.audio.source.volume = + ((item.volume.avg().0 as f64 / Volume::NORMAL.0 as f64) * 100.0).round() as u8; + lock.audio.source.muted = item.mute; + } + } + }); + + Ok(()) +} pub struct AudioModule; impl WaybarModule for AudioModule { - fn run(&self, config: &Config, _state: &SharedState, args: &[&str]) -> Result { + fn run(&self, config: &Config, state: &SharedState, args: &[&str]) -> Result { let target_type = args.first().unwrap_or(&"sink"); let action = args.get(1).unwrap_or(&"show"); match *action { "cycle" => { self.cycle_device(target_type)?; - Ok(WaybarOutput { - text: String::new(), - tooltip: None, - class: None, - percentage: None, - }) + Ok(WaybarOutput::default()) } - "show" => self.get_status(config, target_type), + "show" => self.get_status(config, state, target_type), other => Err(anyhow!("Unknown audio action: '{}'", other)), } } } impl AudioModule { - fn get_status(&self, config: &Config, target_type: &str) -> Result { - let target = if target_type == "sink" { - "@DEFAULT_AUDIO_SINK@" - } else { - "@DEFAULT_AUDIO_SOURCE@" + fn get_status( + &self, + config: &Config, + state: &SharedState, + target_type: &str, + ) -> Result { + let audio_state = { + let lock = state.read().unwrap(); + lock.audio.clone() }; - let stdout = run_command("wpctl", &["get-volume", target])?; + let (name, description, volume, muted) = if target_type == "sink" { + ( + audio_state.sink.name, + audio_state.sink.description, + audio_state.sink.volume, + audio_state.sink.muted, + ) + } else { + ( + audio_state.source.name, + audio_state.source.description, + audio_state.source.volume, + audio_state.source.muted, + ) + }; - let parts: Vec<&str> = stdout.split_whitespace().collect(); - if parts.len() < 2 { - return Err(anyhow!("Could not parse wpctl output: {}", stdout)); + if name.is_empty() { + // Fallback if daemon hasn't populated state yet + return Ok(WaybarOutput { + text: "Audio Loading...".to_string(), + ..Default::default() + }); } - let vol_val: f64 = parts[1].parse().unwrap_or(0.0); - let vol = (vol_val * 100.0).round() as u8; - let display_vol = std::cmp::min(vol, 100); - let muted = stdout.contains("[MUTED]"); - - let description = self.get_description(target_type)?; - let name = if description.len() > 20 { + let display_name = if description.len() > 20 { format!("{}...", &description[..17]) } else { description.clone() @@ -66,16 +231,16 @@ impl AudioModule { let t = format_template( format_str, &[ - ("name", TokenValue::String(&name)), - ("icon", TokenValue::String(icon)), + ("name", TokenValue::String(display_name)), + ("icon", TokenValue::String(icon.to_string())), ], ); (t, "muted") } else { let icon = if target_type == "sink" { - if display_vol <= 30 { + if volume <= 30 { "" - } else if display_vol <= 60 { + } else if volume <= 60 { "" } else { "" @@ -91,9 +256,9 @@ impl AudioModule { let t = format_template( format_str, &[ - ("name", TokenValue::String(&name)), - ("icon", TokenValue::String(icon)), - ("volume", TokenValue::Int(display_vol as i64)), + ("name", TokenValue::String(display_name)), + ("icon", TokenValue::String(icon.to_string())), + ("volume", TokenValue::Int(volume as i64)), ], ); (t, "unmuted") @@ -103,52 +268,32 @@ impl AudioModule { text, tooltip: Some(description), class: Some(class.to_string()), - percentage: Some(display_vol), + percentage: Some(volume), }) } - fn get_description(&self, target_type: &str) -> Result { - let info_stdout = run_command("pactl", &["info"])?; - let search_key = if target_type == "sink" { - "Default Sink:" - } else { - "Default Source:" - }; - - let default_dev = info_stdout - .lines() - .find(|l| l.contains(search_key)) - .and_then(|l| l.split(':').nth(1)) - .map(|s| s.trim()) - .ok_or_else(|| anyhow!("Default {} not found", target_type))?; - - let list_cmd = if target_type == "sink" { - "sinks" - } else { - "sources" - }; - let list_stdout = run_command("pactl", &["list", list_cmd])?; - - let mut current_name = String::new(); - for line in list_stdout.lines() { - if line.trim().starts_with("Name: ") { - current_name = line.split(':').nth(1).unwrap_or("").trim().to_string(); - } - if current_name == default_dev && line.trim().starts_with("Description: ") { - return Ok(line.split(':').nth(1).unwrap_or("").trim().to_string()); - } - } - - Ok(default_dev.to_string()) - } - fn cycle_device(&self, target_type: &str) -> Result<()> { + // Keep using pactl for cycling for now as it's a rare action + // but we could also implement it natively later. + let set_cmd = if target_type == "sink" { + "set-default-sink" + } else { + "set-default-source" + }; + + // We need to find the "next" device. + // For simplicity, let's keep the CLI version for now or refactor later. + // The user asked for "step by step". + let list_cmd = if target_type == "sink" { "sinks" } else { "sources" }; - let stdout = run_command("pactl", &["list", "short", list_cmd])?; + let output = Command::new("pactl") + .args(["list", "short", list_cmd]) + .output()?; + let stdout = String::from_utf8_lossy(&output.stdout); let devices: Vec = stdout .lines() @@ -171,13 +316,13 @@ impl AudioModule { return Ok(()); } - let info_stdout = run_command("pactl", &["info"])?; + let info_output = Command::new("pactl").args(["info"]).output()?; + let info_stdout = String::from_utf8_lossy(&info_output.stdout); let search_key = if target_type == "sink" { "Default Sink:" } else { "Default Source:" }; - let current_dev = info_stdout .lines() .find(|l| l.contains(search_key)) @@ -189,13 +334,7 @@ impl AudioModule { let next_index = (current_index + 1) % devices.len(); let next_dev = &devices[next_index]; - let set_cmd = if target_type == "sink" { - "set-default-sink" - } else { - "set-default-source" - }; Command::new("pactl").args([set_cmd, next_dev]).status()?; - Ok(()) } } diff --git a/src/modules/bt.rs b/src/modules/bt.rs index 0e3ed19..a32d359 100644 --- a/src/modules/bt.rs +++ b/src/modules/bt.rs @@ -1,34 +1,545 @@ use crate::config::Config; use crate::modules::WaybarModule; use crate::output::WaybarOutput; -use crate::state::SharedState; -use crate::utils::{TokenValue, format_template, run_command}; -use anyhow::Result; +use crate::state::{BtState, SharedState}; +use crate::utils::{TokenValue, format_template}; +use anyhow::{Context, Result}; +use futures::StreamExt; +use std::collections::HashMap; use std::process::Command; +use std::sync::{Arc, LazyLock, Mutex}; +use std::time::{Duration, Instant}; +use tokio::sync::mpsc; +use tracing::{debug, error, info, warn}; + +// Maestro imports +#[allow(unused_imports)] +use maestro::protocol::codec::Codec; +#[allow(unused_imports)] +use maestro::pwrpc::client::{Client, ClientHandle}; +#[allow(unused_imports)] +use maestro::service::MaestroService; +#[allow(unused_imports)] +use maestro::service::settings::{self, Setting, SettingValue}; + +static RUNTIME: LazyLock = LazyLock::new(|| { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("Failed to create BT tokio runtime") +}); + +#[derive(Clone, Default)] +struct BudsStatus { + left_battery: Option, + right_battery: Option, + case_battery: Option, + anc_state: String, + #[allow(dead_code)] + last_update: Option, + error: Option, +} + +enum BudsCommand { + SetAnc(String), +} + +enum ManagerCommand { + EnsureTask(String), + SendCommand(String, BudsCommand), +} + +struct MaestroManager { + statuses: Arc>>, + management_tx: mpsc::UnboundedSender, +} + +impl MaestroManager { + fn new() -> Self { + let (tx, mut rx) = mpsc::unbounded_channel::(); + let statuses = Arc::new(Mutex::new(HashMap::new())); + let statuses_clone = Arc::clone(&statuses); + + // Start dedicated BT management thread + std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + let local = tokio::task::LocalSet::new(); + + local.block_on(&rt, async move { + let mut command_txs: HashMap> = HashMap::new(); + + loop { + tokio::select! { + Some(cmd) = rx.recv() => { + match cmd { + ManagerCommand::EnsureTask(mac) => { + if !command_txs.contains_key(&mac) { + let (tx, buds_rx) = mpsc::channel::(10); + command_txs.insert(mac.clone(), tx); + + let mac_clone = mac.clone(); + let st_clone = Arc::clone(&statuses_clone); + + tokio::task::spawn_local(async move { + if let Err(e) = buds_task(&mac_clone, st_clone, buds_rx).await { + error!("Buds task for {} failed: {}", mac_clone, e); + } + }); + } + } + ManagerCommand::SendCommand(mac, buds_cmd) => { + if let Some(tx) = command_txs.get(&mac) { + let _ = tx.try_send(buds_cmd); + } + } + } + } + _ = tokio::time::sleep(Duration::from_millis(100)) => { + // Cleanup dropped tasks if needed + } + } + } + }); + }); + + Self { + statuses, + management_tx: tx, + } + } + + fn get_status(&self, mac: &str) -> BudsStatus { + let statuses = self.statuses.lock().unwrap(); + statuses.get(mac).cloned().unwrap_or_default() + } + + fn ensure_task(&self, mac: &str) { + let _ = self + .management_tx + .send(ManagerCommand::EnsureTask(mac.to_string())); + } + + fn send_command(&self, mac: &str, cmd: BudsCommand) -> Result<()> { + self.ensure_task(mac); + let _ = self + .management_tx + .send(ManagerCommand::SendCommand(mac.to_string(), cmd)); + Ok(()) + } +} + +async fn buds_task( + mac: &str, + statuses: Arc>>, + mut rx: mpsc::Receiver, +) -> Result<()> { + info!("Starting native Maestro connection task for {}", mac); + + loop { + let addr: bluer::Address = mac.parse().context("Failed to parse MAC address")?; + let session = bluer::Session::new() + .await + .context("Failed to create bluer session")?; + let adapter = session + .default_adapter() + .await + .context("Failed to get default adapter")?; + let device = adapter + .device(addr) + .context("Failed to get device handle")?; + + if !device.is_connected().await.unwrap_or(false) { + debug!("Device {} not connected to BT, stopping maestro task", mac); + break; + } + + // Connect to Maestro RFCOMM service + // We try channel 1 then 2, which covers most Pixel Buds variants. + let mut stream = None; + for channel in [1, 2] { + let socket = match bluer::rfcomm::Socket::new() { + Ok(s) => s, + Err(e) => { + error!("Failed to create RFCOMM socket: {}", e); + return Err(e.into()); + } + }; + let target = bluer::rfcomm::SocketAddr::new(addr, channel); + debug!( + "Trying to connect RFCOMM to {} on channel {}...", + mac, channel + ); + match socket.connect(target).await { + Ok(s) => { + stream = Some(s); + break; + } + Err(e) => { + debug!("Failed to connect to channel {}: {}", channel, e); + } + } + } + + let stream = match stream { + Some(s) => s, + None => { + warn!( + "Failed to connect RFCOMM to {} on any common channel. Retrying in 15s...", + mac + ); + tokio::time::sleep(Duration::from_secs(15)).await; + continue; + } + }; + + info!("Connected Maestro RFCOMM to {} on channel", mac); + + // Initialize Maestro communication stack + let codec = Codec::new(); + let stream = codec.wrap(stream); + let mut client = Client::new(stream); + let handle = client.handle(); + + // Resolve Maestro channel (typically 1 or 2) + let channel = match maestro::protocol::utils::resolve_channel(&mut client).await { + Ok(c) => c, + Err(e) => { + error!("Failed to resolve Maestro channel for {}: {}", mac, e); + continue; + } + }; + + // Run client in background to handle RPC packets + tokio::spawn(async move { + if let Err(e) = client.run().await { + error!("Maestro client loop error: {}", e); + } + }); + + let mut service = MaestroService::new(handle, channel); + + // Query initial ANC state + if let Ok(val) = service + .read_setting_var(settings::SettingId::CurrentAncrState) + .await + { + if let SettingValue::CurrentAncrState(anc_state) = val { + let mut status = MAESTRO.get_status(mac); + status.anc_state = anc_state_to_string(&anc_state); + statuses.lock().unwrap().insert(mac.to_string(), status); + } + } + + // Subscribe to real-time status updates (battery, ANC, wear) + let mut call = match service.subscribe_to_runtime_info() { + Ok(c) => c, + Err(e) => { + error!("Failed to subscribe to runtime info for {}: {}", mac, e); + continue; + } + }; + + let mut runtime_info = call.stream(); + + debug!("Subscribed to runtime info for {}", mac); + + loop { + tokio::select! { + cmd = rx.recv() => { + match cmd { + Some(BudsCommand::SetAnc(mode)) => { + debug!("Setting ANC mode to {} for {}", mode, mac); + let state = mode_to_anc_state(&mode); + let val = SettingValue::CurrentAncrState(state); + if let Err(e) = service.write_setting(val).await { + error!("Failed to write ANC setting for {}: {}", mac, e); + } + } + None => return Ok(()), + } + } + Some(Ok(info)) = runtime_info.next() => { + let mut status = MAESTRO.get_status(mac); + status.last_update = Some(Instant::now()); + + if let Some(bat) = info.battery_info { + status.left_battery = bat.left.map(|b| b.level as u8); + status.right_battery = bat.right.map(|b| b.level as u8); + status.case_battery = bat.case.map(|b| b.level as u8); + } + + statuses.lock().unwrap().insert(mac.to_string(), status); + } + _ = tokio::time::sleep(Duration::from_secs(30)) => { + // Check if still connected to BT + if !device.is_connected().await.unwrap_or(false) { + break; + } + } + } + } + + if !device.is_connected().await.unwrap_or(false) { + break; + } + } + + Ok(()) +} + +fn mode_to_anc_state(mode: &str) -> settings::AncState { + match mode { + "active" => settings::AncState::Active, + "aware" => settings::AncState::Aware, + "off" => settings::AncState::Off, + _ => settings::AncState::Off, + } +} + +fn anc_state_to_string(state: &settings::AncState) -> String { + match state { + settings::AncState::Active => "active".to_string(), + settings::AncState::Aware => "aware".to_string(), + settings::AncState::Off => "off".to_string(), + _ => "unknown".to_string(), + } +} + +static MAESTRO: LazyLock = LazyLock::new(MaestroManager::new); + +pub struct BtDaemon { + session: Option, +} + +impl BtDaemon { + pub fn new() -> Self { + Self { session: None } + } + + pub fn poll(&mut self, state: SharedState) { + if let Err(e) = RUNTIME.block_on(self.poll_async(state)) { + error!("BT daemon error: {}", e); + } + } + + async fn poll_async(&mut self, state: SharedState) -> Result<()> { + if self.session.is_none() { + self.session = Some(bluer::Session::new().await?); + } + let session = self.session.as_ref().unwrap(); + let adapter = session.default_adapter().await?; + let adapter_powered = adapter.is_powered().await.unwrap_or(false); + + let mut bt_state = BtState { + adapter_powered, + ..Default::default() + }; + + if adapter_powered { + let devices = adapter.device_addresses().await?; + for addr in devices { + let device = adapter.device(addr)?; + if device.is_connected().await.unwrap_or(false) { + let uuids = device.uuids().await?.unwrap_or_default(); + // Audio sink UUID (0x110b) + let audio_sink_uuid = + bluer::Uuid::from_u128(0x0000110b_0000_1000_8000_00805f9b34fb); + if uuids.contains(&audio_sink_uuid) { + bt_state.connected = true; + bt_state.device_address = addr.to_string(); + bt_state.device_alias = + device.alias().await.unwrap_or_else(|_| addr.to_string()); + bt_state.battery_percentage = + device.battery_percentage().await.unwrap_or(None); + + // Plugin detection + for p in PLUGINS.iter() { + if p.can_handle(&bt_state.device_alias, &bt_state.device_address) { + match p.get_data(&bt_state.device_address) { + Ok(data) => { + bt_state.plugin_data = data + .into_iter() + .map(|(k, v)| { + let val_str = match v { + TokenValue::String(s) => s, + TokenValue::Int(i) => i.to_string(), + TokenValue::Float(f) => format!("{:.1}", f), + }; + (k, val_str) + }) + .collect(); + } + Err(e) => { + warn!("Plugin {} failed for {}: {}", p.name(), addr, e); + bt_state + .plugin_data + .push(("plugin_error".to_string(), e.to_string())); + } + } + break; + } + } + break; + } + } + } + } + + if let Ok(mut lock) = state.write() { + lock.bluetooth = bt_state; + } + + Ok(()) + } +} + +pub trait BtPlugin: Send + Sync { + fn name(&self) -> &str; + fn can_handle(&self, alias: &str, mac: &str) -> bool; + fn get_data(&self, mac: &str) -> Result>; + fn get_modes(&self, _mac: &str) -> Result> { + Ok(vec![]) + } + fn set_mode(&self, _mode: &str, _mac: &str) -> Result<()> { + Ok(()) + } + fn cycle_mode(&self, _mac: &str) -> Result<()> { + Ok(()) + } +} + +pub struct PixelBudsPlugin; + +impl BtPlugin for PixelBudsPlugin { + fn name(&self) -> &str { + "Pixel Buds Pro" + } + + fn can_handle(&self, alias: &str, _mac: &str) -> bool { + alias.contains("Pixel Buds Pro") + } + + fn get_data(&self, mac: &str) -> Result> { + MAESTRO.ensure_task(mac); + let status = MAESTRO.get_status(mac); + + if let Some(err) = status.error { + return Err(anyhow::anyhow!(err)); + } + + let left_display = status + .left_battery + .map(|b| format!("{}%", b)) + .unwrap_or_else(|| "---".to_string()); + let right_display = status + .right_battery + .map(|b| format!("{}%", b)) + .unwrap_or_else(|| "---".to_string()); + + let (anc_icon, class) = match status.anc_state.as_str() { + "active" => ("ANC", "anc-active"), + "aware" => ("Aware", "anc-aware"), + "off" => ("Off", "anc-off"), + _ => ("?", "anc-unknown"), + }; + + Ok(vec![ + ("left".to_string(), TokenValue::String(left_display)), + ("right".to_string(), TokenValue::String(right_display)), + ("anc".to_string(), TokenValue::String(anc_icon.to_string())), + ( + "plugin_class".to_string(), + TokenValue::String(class.to_string()), + ), + ]) + } + + fn get_modes(&self, _mac: &str) -> Result> { + Ok(vec![ + "active".to_string(), + "aware".to_string(), + "off".to_string(), + ]) + } + + fn set_mode(&self, mode: &str, mac: &str) -> Result<()> { + MAESTRO.send_command(mac, BudsCommand::SetAnc(mode.to_string())) + } + + fn cycle_mode(&self, mac: &str) -> Result<()> { + let status = MAESTRO.get_status(mac); + let next_mode = match status.anc_state.as_str() { + "active" => "aware", + "aware" => "off", + _ => "active", + }; + self.set_mode(next_mode, mac) + } +} + +static PLUGINS: LazyLock>> = + LazyLock::new(|| vec![Box::new(PixelBudsPlugin)]); pub struct BtModule; impl WaybarModule for BtModule { - fn run(&self, config: &Config, _state: &SharedState, args: &[&str]) -> Result { + fn run(&self, config: &Config, state: &SharedState, args: &[&str]) -> Result { let action = args.first().unwrap_or(&"show"); + let bt_state = { + let lock = state.read().unwrap(); + lock.bluetooth.clone() + }; - if *action == "disconnect" { - if let Some(mac) = find_audio_device() { + match *action { + "disconnect" if bt_state.connected => { let _ = Command::new("bluetoothctl") - .args(["disconnect", &mac]) + .args(["disconnect", &bt_state.device_address]) .output(); + return Ok(WaybarOutput::default()); } - return Ok(WaybarOutput { - text: String::new(), - tooltip: None, - class: None, - percentage: None, - }); + "cycle_mode" if bt_state.connected => { + let plugin = PLUGINS + .iter() + .find(|p| p.can_handle(&bt_state.device_alias, &bt_state.device_address)); + if let Some(p) = plugin { + p.cycle_mode(&bt_state.device_address)?; + } + return Ok(WaybarOutput::default()); + } + "get_modes" if bt_state.connected => { + let plugin = PLUGINS + .iter() + .find(|p| p.can_handle(&bt_state.device_alias, &bt_state.device_address)); + let modes = if let Some(p) = plugin { + p.get_modes(&bt_state.device_address)? + } else { + vec![] + }; + return Ok(WaybarOutput { + text: modes.join("\n"), + ..Default::default() + }); + } + "set_mode" if bt_state.connected => { + if let Some(mode) = args.get(1) { + let plugin = PLUGINS + .iter() + .find(|p| p.can_handle(&bt_state.device_alias, &bt_state.device_address)); + if let Some(p) = plugin { + p.set_mode(mode, &bt_state.device_address)?; + } + } + return Ok(WaybarOutput::default()); + } + "show" => {} + _ => {} } - if let Ok(stdout) = run_command("bluetoothctl", &["show"]) - && stdout.contains("Powered: no") - { + if !bt_state.adapter_powered { return Ok(WaybarOutput { text: config.bt.format_disabled.clone(), tooltip: Some("Bluetooth Disabled".to_string()), @@ -37,46 +548,54 @@ impl WaybarModule for BtModule { }); } - if let Some(mac) = find_audio_device() { - let info = run_command("bluetoothctl", &["info", &mac])?; + if bt_state.connected { + let mut tokens: Vec<(String, TokenValue)> = vec![ + ( + "alias".to_string(), + TokenValue::String(bt_state.device_alias.clone()), + ), + ( + "mac".to_string(), + TokenValue::String(bt_state.device_address.clone()), + ), + ]; - let mut alias = mac.clone(); - let mut battery = None; - let mut trusted = "no"; + let mut class = vec!["connected".to_string()]; + let mut has_plugin = false; - for line in info.lines() { - if line.contains("Alias:") { - alias = line.split("Alias:").nth(1).unwrap_or("").trim().to_string(); - } else if line.contains("Battery Percentage:") { - if let Some(bat_str) = line.split('(').nth(1).and_then(|s| s.split(')').next()) - { - battery = bat_str.parse::().ok(); - } - } else if line.contains("Trusted: yes") { - trusted = "yes"; + for (k, v) in &bt_state.plugin_data { + if k == "plugin_class" { + class.push(v.clone()); + has_plugin = true; + } else if k == "plugin_error" { + class.push("plugin-error".to_string()); + } else { + tokens.push((k.clone(), TokenValue::String(v.clone()))); } } + let format = if has_plugin { + &config.bt.format_plugin + } else { + &config.bt.format_connected + }; + + let text = format_template(format, &tokens); let tooltip = format!( - "{} | MAC: {}\nTrusted: {} | Bat: {}", - alias, - mac, - trusted, - battery + "{} | MAC: {}\nBattery: {}", + bt_state.device_alias, + bt_state.device_address, + bt_state + .battery_percentage .map(|b| format!("{}%", b)) .unwrap_or_else(|| "N/A".to_string()) ); - let text = format_template( - &config.bt.format_connected, - &[("alias", TokenValue::String(&alias))], - ); - Ok(WaybarOutput { text, tooltip: Some(tooltip), - class: Some("connected".to_string()), - percentage: battery, + class: Some(class.join(" ")), + percentage: bt_state.battery_percentage, }) } else { Ok(WaybarOutput { @@ -88,32 +607,3 @@ impl WaybarModule for BtModule { } } } - -fn find_audio_device() -> Option { - if let Ok(sink) = run_command("pactl", &["get-default-sink"]) - && sink.starts_with("bluez_output.") - { - let parts: Vec<&str> = sink.split('.').collect(); - if parts.len() >= 2 { - return Some(parts[1].replace('_', ":")); - } - } - - if let Ok(stdout) = run_command("bluetoothctl", &["devices", "Connected"]) { - for line in stdout.lines() { - if line.starts_with("Device ") { - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() >= 2 { - let mac = parts[1]; - if let Ok(info_str) = run_command("bluetoothctl", &["info", mac]) - && info_str.contains("0000110b-0000-1000-8000-00805f9b34fb") - { - return Some(mac.to_string()); - } - } - } - } - } - - None -} diff --git a/src/modules/buds.rs b/src/modules/buds.rs deleted file mode 100644 index d6cf38b..0000000 --- a/src/modules/buds.rs +++ /dev/null @@ -1,136 +0,0 @@ -use crate::config::Config; -use crate::modules::WaybarModule; -use crate::output::WaybarOutput; -use crate::state::SharedState; -use crate::utils::{TokenValue, format_template, run_command}; -use anyhow::Result; - -pub struct BudsModule; - -impl WaybarModule for BudsModule { - fn run(&self, config: &Config, _state: &SharedState, args: &[&str]) -> Result { - let action = args.first().unwrap_or(&"show"); - let mac = &config.buds.mac; - - match *action { - "cycle_anc" => { - let current_mode = run_command("pbpctrl", &["get", "anc"])?; - - let next_mode = match current_mode.as_str() { - "active" => "aware", - "aware" => "off", - _ => "active", - }; - - let _ = run_command("pbpctrl", &["set", "anc", next_mode]); - return Ok(WaybarOutput { - text: String::new(), - tooltip: None, - class: None, - percentage: None, - }); - } - "connect" => { - let _ = run_command("bluetoothctl", &["connect", mac]); - return Ok(WaybarOutput { - text: String::new(), - tooltip: None, - class: None, - percentage: None, - }); - } - "disconnect" => { - let _ = run_command("bluetoothctl", &["disconnect", mac]); - return Ok(WaybarOutput { - text: String::new(), - tooltip: None, - class: None, - percentage: None, - }); - } - "show" => {} - other => { - return Err(anyhow::anyhow!("Unknown buds action: '{}'", other)); - } - } - - let bt_str = run_command("bluetoothctl", &["info", mac])?; - - if !bt_str.contains("Connected: yes") { - return Ok(WaybarOutput { - text: config.buds.format_disconnected.clone(), - tooltip: Some("Pixel Buds Pro 2 not connected".to_string()), - class: Some("disconnected".to_string()), - percentage: None, - }); - } - - let bat_output = match run_command("pbpctrl", &["show", "battery"]) { - Ok(output) => output, - Err(_) => { - return Ok(WaybarOutput { - text: config.buds.format_disconnected.clone(), - tooltip: Some("Pixel Buds Pro 2 connected (No Data)".to_string()), - class: Some("disconnected".to_string()), - percentage: None, - }); - } - }; - - let mut left_bud = "unknown"; - let mut right_bud = "unknown"; - - for line in bat_output.lines() { - if line.contains("left bud:") { - left_bud = line.split_whitespace().nth(2).unwrap_or("unknown"); - } else if line.contains("right bud:") { - right_bud = line.split_whitespace().nth(2).unwrap_or("unknown"); - } - } - - if left_bud == "unknown" && right_bud == "unknown" { - return Ok(WaybarOutput { - text: "{}".to_string(), - tooltip: None, - class: None, - percentage: None, - }); - } - - let left_display = if left_bud == "unknown" { - "---".to_string() - } else { - format!("{}%", left_bud) - }; - let right_display = if right_bud == "unknown" { - "---".to_string() - } else { - format!("{}%", right_bud) - }; - - let current_mode = run_command("pbpctrl", &["get", "anc"]).unwrap_or_default(); - - let (anc_icon, class) = match current_mode.as_str() { - "active" => ("ANC", "anc-active"), - "aware" => ("Aware", "anc-aware"), - "off" => ("Off", "anc-off"), - _ => ("?", "anc-unknown"), - }; - - let text = format_template( - &config.buds.format, - &[ - ("left", TokenValue::String(&left_display)), - ("right", TokenValue::String(&right_display)), - ("anc", TokenValue::String(anc_icon)), - ], - ); - - Ok(WaybarOutput { - text, - tooltip: Some("Pixel Buds Pro 2".to_string()), - class: Some(class.to_string()), - percentage: None, - }) - } -} diff --git a/src/modules/disk.rs b/src/modules/disk.rs index d333101..79543c7 100644 --- a/src/modules/disk.rs +++ b/src/modules/disk.rs @@ -44,7 +44,7 @@ impl WaybarModule for DiskModule { let text = format_template( &config.disk.format, &[ - ("mount", TokenValue::String(mountpoint)), + ("mount", TokenValue::String(mountpoint.to_string())), ("used", TokenValue::Float(used_gb)), ("total", TokenValue::Float(total_gb)), ], diff --git a/src/modules/game.rs b/src/modules/game.rs index 421997f..6e126cf 100644 --- a/src/modules/game.rs +++ b/src/modules/game.rs @@ -2,14 +2,16 @@ use crate::config::Config; use crate::modules::WaybarModule; use crate::output::WaybarOutput; use crate::state::SharedState; -use crate::utils::run_command; -use anyhow::Result; +use anyhow::{Result, anyhow}; +use std::env; +use std::io::{Read, Write}; +use std::os::unix::net::UnixStream; pub struct GameModule; impl WaybarModule for GameModule { fn run(&self, config: &Config, _state: &SharedState, _args: &[&str]) -> Result { - let is_gamemode = run_command("hyprctl", &["getoption", "animations:enabled", "-j"]) + let is_gamemode = hyprland_ipc("j/getoption animations:enabled") .map(|stdout| stdout.contains("\"int\": 0")) .unwrap_or(false); @@ -30,3 +32,17 @@ impl WaybarModule for GameModule { } } } + +fn hyprland_ipc(cmd: &str) -> Result { + let signature = env::var("HYPRLAND_INSTANCE_SIGNATURE") + .map_err(|_| anyhow!("HYPRLAND_INSTANCE_SIGNATURE not set"))?; + let path = format!("/tmp/hypr/{}/.socket.sock", signature); + + let mut stream = UnixStream::connect(path)?; + stream.write_all(cmd.as_bytes())?; + + let mut response = String::new(); + stream.read_to_string(&mut response)?; + + Ok(response) +} diff --git a/src/modules/hardware.rs b/src/modules/hardware.rs index 661dfa1..0ca063b 100644 --- a/src/modules/hardware.rs +++ b/src/modules/hardware.rs @@ -20,7 +20,7 @@ impl HardwareDaemon { components, gpu_vendor: None, gpu_poll_counter: 0, - disk_poll_counter: 0, + disk_poll_counter: 9, // Start at 9 to poll on the first tick } } @@ -70,6 +70,32 @@ impl HardwareDaemon { } } + // 1. Gather GPU data outside of lock + let mut gpu_state = crate::state::GpuState::default(); + self.gpu_poll_counter = (self.gpu_poll_counter + 1) % 5; + let should_poll_gpu = self.gpu_poll_counter == 0; + if should_poll_gpu { + self.poll_gpu(&mut gpu_state); + } + + // 2. Gather Disk data outside of lock + let mut disks_data = None; + self.disk_poll_counter = (self.disk_poll_counter + 1) % 10; + if self.disk_poll_counter == 0 { + disks_data = Some( + Disks::new_with_refreshed_list() + .iter() + .map(|d| DiskInfo { + mount_point: d.mount_point().to_string_lossy().into_owned(), + filesystem: d.file_system().to_string_lossy().to_lowercase(), + total_bytes: d.total_space(), + available_bytes: d.available_space(), + }) + .collect::>(), + ); + } + + // 3. Apply everything to state in one short lock if let Ok(mut state_lock) = state.write() { state_lock.cpu.usage = cpu_usage as f64; state_lock.cpu.temp = cpu_temp; @@ -84,24 +110,12 @@ impl HardwareDaemon { state_lock.sys.uptime = uptime; state_lock.sys.process_count = process_count; - // Poll GPU every 5 seconds to avoid expensive nvidia-smi calls - self.gpu_poll_counter = (self.gpu_poll_counter + 1) % 5; - if self.gpu_poll_counter == 0 { - self.poll_gpu(&mut state_lock.gpu); + if should_poll_gpu { + state_lock.gpu = gpu_state; } - // Poll disks every 10 seconds - self.disk_poll_counter = (self.disk_poll_counter + 1) % 10; - if self.disk_poll_counter == 0 { - state_lock.disks = Disks::new_with_refreshed_list() - .iter() - .map(|d| DiskInfo { - mount_point: d.mount_point().to_string_lossy().into_owned(), - filesystem: d.file_system().to_string_lossy().to_lowercase(), - total_bytes: d.total_space(), - available_bytes: d.available_space(), - }) - .collect(); + if let Some(d) = disks_data { + state_lock.disks = d; } } } diff --git a/src/modules/mod.rs b/src/modules/mod.rs index d41e049..ec5a593 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -1,7 +1,6 @@ pub mod audio; pub mod bt; pub mod btrfs; -pub mod buds; pub mod cpu; pub mod disk; pub mod game; diff --git a/src/modules/network.rs b/src/modules/network.rs index bbe4b26..9fa1355 100644 --- a/src/modules/network.rs +++ b/src/modules/network.rs @@ -2,8 +2,9 @@ use crate::config::Config; use crate::modules::WaybarModule; use crate::output::WaybarOutput; use crate::state::SharedState; -use crate::utils::{TokenValue, format_template, run_command}; +use crate::utils::{TokenValue, format_template}; use anyhow::Result; +use nix::ifaddrs::getifaddrs; use std::fs; use std::time::{SystemTime, UNIX_EPOCH}; @@ -29,21 +30,18 @@ impl NetworkDaemon { } pub fn poll(&mut self, state: SharedState) { - // Cache invalidation: if the interface directory doesn't exist, clear cache - if let Some(ref iface) = self.cached_interface - && !std::path::Path::new(&format!("/sys/class/net/{}", iface)).exists() - { - self.cached_interface = None; - self.cached_ip = None; - } - - // Re-detect interface if needed - if self.cached_interface.is_none() - && let Ok(iface) = get_primary_interface() + // Re-detect interface on every poll to catch VPNs or route changes immediately + if let Ok(iface) = get_primary_interface() && !iface.is_empty() { - self.cached_ip = get_ip_address(&iface); - self.cached_interface = Some(iface); + // If the interface changed, or we don't have an IP yet, update the IP + if self.cached_interface.as_ref() != Some(&iface) || self.cached_ip.is_none() { + self.cached_ip = get_ip_address(&iface); + self.cached_interface = Some(iface); + } + } else { + self.cached_interface = None; + self.cached_ip = None; } if let Some(ref interface) = self.cached_interface { @@ -123,8 +121,8 @@ impl WaybarModule for NetworkModule { let mut output_text = format_template( &config.network.format, &[ - ("interface", TokenValue::String(&interface)), - ("ip", TokenValue::String(ip_display)), + ("interface", TokenValue::String(interface.clone())), + ("ip", TokenValue::String(ip_display.to_string())), ("rx", TokenValue::Float(rx_mbps)), ("tx", TokenValue::Float(tx_mbps)), ], @@ -134,6 +132,8 @@ impl WaybarModule for NetworkModule { || interface.starts_with("wg") || interface.starts_with("ppp") || interface.starts_with("pvpn") + || interface.starts_with("proton") + || interface.starts_with("ipsec") { output_text = format!(" {}", output_text); } @@ -148,24 +148,18 @@ impl WaybarModule for NetworkModule { } fn get_primary_interface() -> Result { - let stdout = run_command("ip", &["route", "list"])?; + let content = fs::read_to_string("/proc/net/route")?; let mut defaults = Vec::new(); - for line in stdout.lines() { - if line.starts_with("default") { - let parts: Vec<&str> = line.split_whitespace().collect(); - let mut dev = ""; - let mut metric = 0; - for i in 0..parts.len() { - if parts[i] == "dev" && i + 1 < parts.len() { - dev = parts[i + 1]; - } - if parts[i] == "metric" && i + 1 < parts.len() { - metric = parts[i + 1].parse::().unwrap_or(0); - } - } - if !dev.is_empty() { - defaults.push((metric, dev.to_string())); + for line in content.lines().skip(1) { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 7 { + let iface = parts[0]; + let dest = parts[1]; + let metric = parts[6].parse::().unwrap_or(0); + + if dest == "00000000" { + defaults.push((metric, iface.to_string())); } } } @@ -179,14 +173,13 @@ fn get_primary_interface() -> Result { } fn get_ip_address(interface: &str) -> Option { - let stdout = run_command("ip", &["-4", "addr", "show", interface]).ok()?; - for line in stdout.lines() { - if line.trim().starts_with("inet ") { - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() > 1 { - let ip_cidr = parts[1]; - let ip = ip_cidr.split('/').next().unwrap_or(ip_cidr); - return Some(ip.to_string()); + let addrs = getifaddrs().ok()?; + for ifaddr in addrs { + if ifaddr.interface_name == interface { + if let Some(address) = ifaddr.address { + if let Some(sockaddr) = address.as_sockaddr_in() { + return Some(sockaddr.ip().to_string()); + } } } } diff --git a/src/modules/power.rs b/src/modules/power.rs index 5bf7152..b537dea 100644 --- a/src/modules/power.rs +++ b/src/modules/power.rs @@ -96,7 +96,7 @@ impl WaybarModule for PowerModule { &config.power.format, &[ ("percentage", TokenValue::Int(percentage as i64)), - ("icon", TokenValue::String(icon)), + ("icon", TokenValue::String(icon.to_string())), ], ); diff --git a/src/modules/sys.rs b/src/modules/sys.rs index 9831d37..fceb486 100644 --- a/src/modules/sys.rs +++ b/src/modules/sys.rs @@ -34,7 +34,7 @@ impl WaybarModule for SysModule { let text = format_template( &config.sys.format, &[ - ("uptime", TokenValue::String(&uptime_str)), + ("uptime", TokenValue::String(uptime_str.clone())), ("load1", TokenValue::Float(load1)), ("load5", TokenValue::Float(load5)), ("load15", TokenValue::Float(load15)), diff --git a/src/output.rs b/src/output.rs index 61617cb..b35658a 100644 --- a/src/output.rs +++ b/src/output.rs @@ -11,6 +11,17 @@ pub struct WaybarOutput { pub percentage: Option, } +impl Default for WaybarOutput { + fn default() -> Self { + Self { + text: String::new(), + tooltip: None, + class: None, + percentage: None, + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/state.rs b/src/state.rs index 29f5082..f634c19 100644 --- a/src/state.rs +++ b/src/state.rs @@ -8,6 +8,40 @@ pub struct AppState { pub sys: SysState, pub gpu: GpuState, pub disks: Vec, + pub bluetooth: BtState, + pub audio: AudioState, +} + +#[derive(Default, Clone)] +pub struct AudioState { + pub sink: AudioDeviceInfo, + pub source: AudioSourceInfo, +} + +#[derive(Default, Clone)] +pub struct AudioDeviceInfo { + pub name: String, + pub description: String, + pub volume: u8, + pub muted: bool, +} + +#[derive(Default, Clone)] +pub struct AudioSourceInfo { + pub name: String, + pub description: String, + pub volume: u8, + pub muted: bool, +} + +#[derive(Default, Clone)] +pub struct BtState { + pub connected: bool, + pub adapter_powered: bool, + pub device_alias: String, + pub device_address: String, + pub battery_percentage: Option, + pub plugin_data: Vec<(String, String)>, } #[derive(Default, Clone)] diff --git a/src/test_pbpctrl.rs b/src/test_pbpctrl.rs new file mode 100644 index 0000000..2dd09fe --- /dev/null +++ b/src/test_pbpctrl.rs @@ -0,0 +1,5 @@ +use pbpctrl::Controller; + +fn main() { + let _ = Controller::connect("AA:BB:CC:DD:EE:FF"); +} diff --git a/src/utils.rs b/src/utils.rs index 59e00aa..7d4494f 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -2,20 +2,6 @@ use anyhow::{Context, Result}; use std::io::Write; use std::process::{Command, Stdio}; -/// Run an external command and return its stdout as a trimmed String. -/// Provides clear error messages when the command is not found or fails. -pub fn run_command(cmd: &str, args: &[&str]) -> Result { - let output = Command::new(cmd) - .args(args) - .output() - .with_context(|| format!("'{}' not found or failed to execute. Is it installed?", cmd))?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - anyhow::bail!("'{}' exited with {}: {}", cmd, output.status, stderr.trim()); - } - Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) -} - pub fn show_menu(prompt: &str, items: &[String], menu_cmd: &str) -> Result { let cmd_str = menu_cmd.replace("{prompt}", prompt); let mut child = Command::new("sh") @@ -50,20 +36,23 @@ pub fn show_menu(prompt: &str, items: &[String], menu_cmd: &str) -> Result { +pub enum TokenValue { Float(f64), Int(i64), - String(&'a str), + String(String), } -pub fn format_template(template: &str, values: &[(&str, TokenValue)]) -> String { +pub fn format_template(template: &str, values: &[(K, TokenValue)]) -> String +where + K: AsRef, +{ static RE: LazyLock = LazyLock::new(|| { Regex::new(r"\{([a-zA-Z0-9_]+)(?::([<>\^])?(\d+)?(?:\.(\d+))?)?\}").unwrap() }); RE.replace_all(template, |caps: ®ex::Captures| { let name = &caps[1]; - if let Some((_, val)) = values.iter().find(|(k, _)| *k == name) { + if let Some((_, val)) = values.iter().find(|(k, _)| k.as_ref() == name) { let align = caps.get(2).map(|m| m.as_str()).unwrap_or(">"); let width = caps .get(3) @@ -121,7 +110,10 @@ mod tests { #[test] fn test_simple_string_token() { - let result = format_template("{name}", &[("name", TokenValue::String("hello"))]); + let result = format_template( + "{name}", + &[("name", TokenValue::String("hello".to_string()))], + ); assert_eq!(result, "hello"); } @@ -166,14 +158,20 @@ mod tests { #[test] fn test_string_left_align() { - let result = format_template("{val:<10}", &[("val", TokenValue::String("hi"))]); + let result = format_template( + "{val:<10}", + &[("val", TokenValue::String("hi".to_string()))], + ); assert_eq!(result, "hi "); assert_eq!(result.len(), 10); } #[test] fn test_unknown_token_preserved() { - let result = format_template("{unknown}", &[("name", TokenValue::String("test"))]); + let result = format_template( + "{unknown}", + &[("name", TokenValue::String("test".to_string()))], + ); assert_eq!(result, "{unknown}"); } @@ -206,8 +204,8 @@ mod tests { let result = format_template( "{name} ({ip}): {rx:>5.2} MB/s", &[ - ("name", TokenValue::String("eth0")), - ("ip", TokenValue::String("10.0.0.1")), + ("name", TokenValue::String("eth0".to_string())), + ("ip", TokenValue::String("10.0.0.1".to_string())), ("rx", TokenValue::Float(1.5)), ], );