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)),
],
);